Добрый день, уважаемые читатели!

В данной статье я бы хотел способом создания бота телеграм на C# с помощью набора инструментов SKitLs.Bots.Telegram - молодого проекта, созданного с целью упрощения процесса создания ботов.

Само по себе решение создано с целью превращения процесса создания ботов из рутинного написания if-else в своеобразный дизайн-конструктор на основе возможностей .NET и C#.

О SKitLs.Bots.Telegram

Большая часть существующих методичек по тг-ботам на C# опирается на библиотеки Telegram.Bot и Telegram.Bot.Extentions.Polling (функционал которого, к слову, в последних выпусках перекочевал в сам Telegram.Bot).

Тем не менее, Telegram.Bot является лишь мостом между клиентом и сервером, обеспечивающим связь с API Telegram.

В то же время идея решения SKitLs заключается в автоматизации типизации входящих обновлений и предоставления инструментов для гибкого создания модели приложения, предлагая уже готовую, свою собственную архитектуру.

В данной статье мы опробуем эту архитектуру на конкретном кейсе: создадим примитивного бота, позволяющего просматривать погоду в городах с помощью API сервиса Яндекс.Погода и сохранять определённые города в список избранного.

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

Итоговой проект размещён на GitHub и доступен для ознакомления.

Не будем медлить

Создав консольный проект и установив nuget SKitLs.Bots.Telegram.Core - ключевой модуль решения, - я позволил себе скопировать код из "Quick Start" гитхаб страницы проекта, чтобы убедиться в релевантности данного решения.

static async Task Main(string[] args)
{
    var privateMessages = new DefaultSignedMessageUpdateHandler();
    var privateTexts = new DefaultSignedMessageTextUpdateHandler
    {
        CommandsManager = new DefaultActionManager<SignedMessageTextUpdate>()
    };
    privateTexts.CommandsManager.AddSafely(StartCommand);
    privateMessages.TextMessageUpdateHandler = privateTexts;

    ChatDesigner privates = ChatDesigner.NewDesigner()
       .UseMessageHandler(privateMessages);

    await BotBuilder.NewBuilder("YOUR_TOKEN")
       .EnablePrivates(privates)
       .Build()
       .Listen();
}

private static DefaultCommand StartCommand => new("start", Do_StartAsync);
private static async Task Do_StartAsync(SignedMessageTextUpdate update)
{
    await update.Owner.DeliveryService.ReplyToSender("Hello, world!", update);
}

Что же, всё действительно работает.

Реакция на /start
Реакция на /start

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

Скрытая ошибка
Скрытая ошибка

Дело в том, что решения SKitLs.Bots.Telegram поддерживают отладку на различных языках. Для качественной работы проекта необходимо также скачать нужные языковые пакеты, опять же, со страницы гитхаб из папки "locals".

Как только мы это сделали, укажем путь к нашей папке и затем попробуем обновить язык отладки. По умолчанию, решение подгружает языковые пакеты из папки "resources/locals", но мы укажем это явно, для полноты картины.

Дерево решения. Языковые пакеты
Дерево решения. Языковые пакеты
static async Task Main(string[] args)
{
    BotBuilder.DebugSettings.DebugLanguage = LangKey.RU;
    BotBuilder.DebugSettings.UpdateLocalsPath("resources/locals");
  
    var privateMessages = new DefaultSignedMessageUpdateHandler();
    // ...
}

Что же, на данном этапе наша затея со сменной языка отладки провалилась: вывод остался на английском. Остаётся надеяться, что в будущих версиях (на момента написания последняя актуальная версия - 1.4.3) это будет исправлено.

Сейчас же, когда все настройки произведены, вернёмся непосредственно к созданию бота.

Проектирование

Итак, как я вижу данное решение?

Пользователь должен иметь возможность: посмотреть погоду в городе, добавить город в избранное, просмотреть избранное, удалить из избранного.

Всё, что касается хранения и манипуляции данных, в идеале может решаться через SKitLs.Bots.Telegram.DataBases. Однако на момент написания статьи данное решение, хоть и числится, но ещё не выпущено в виде пакета, а потому будем импровизировать.

Реализовать функционал бота я планирую с помощью одной команды '/start' и проекта SKitLs.Bots.Telegram.PageNavs (1.3.1, также установлен через NuGet), позволяющего создавать мини-навигацию в рамках одного сообщения: с историей передвижений и кнопками. Это позволит избежать добавления чрезмерного количества команд.

*.PageNavs зависим от *.AdvancedMessages и *.ArgedInteractions, которые также потребуется установить.

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

Реализация состояний

В данном блоке вкратце покажу функциональную возможность состояний. Чтобы состояния сохранялись после каждой обработки событий и не сбрасывались - они должны храниться в памяти бота. Для этого необходима реализация произвольного UserManager : IUserManager, для которого, в свою очередь, понадобится кастомный класс пользователя, поддерживающий состояния, типа BotUser : IStatefulUser. Создадим их.

internal class BotUser : IStatefulUser
{
    public IUserState State { get; set; }
    public long TelegramId { get; set; }

    public BotUser(long telegramId)
    {
        State = new DefaultUserState();
        TelegramId = telegramId;
    }

    public void ResetState() => State = new DefaultUserState();
}
internal class UserManager : IUsersManager
{
    public UserDataChanged? SignedEventHandled => null;

    public List<BotUser> Users { get; } = new();

    public async Task<IBotUser?> GetUserById(long telegramId) => await Task.FromResult(Users.Find(x => x.TelegramId ==  telegramId));

    public async Task<bool> IsUserRegistered(long telegramId) => await GetUserById(telegramId) is not null;

    public async Task<IBotUser?> RegisterNewUser(ICastedUpdate update)
    {
        var user = ChatScanner.GetSender(update.OriginalSource, this)!;
        var @new = new BotUser(user.Id);
        Users.Add(@new);
        return await Task.FromResult(@new);
    }
}

Теперь подключим UserManager к нашему боту в ChatDesigner и перепишем код '/start', чтобы было наглядно видно, как это работает.

static async Task Main(string[] args)
{
    // ...

    ChatDesigner privates = ChatDesigner.NewDesigner()
        .UseUsersManager(new UserManager())
        .UseMessageHandler(privateMessages);
    
    // ...
}

private static async Task Do_StartAsync(SignedMessageTextUpdate update)
{
    if (update.Sender is IStatefulUser sender)
    {
        int st = (sender.State.StateId + 1) % 2;
        var text = st == 0
            ? "Start command запущена из состояния 0"
            : "Абсолютно другой текст, в котором сказано, что состояния - 1";
        await update.Owner.DeliveryService.ReplyToSender(text, update);
        sender.State = new DefaultUserState(st);
    }
}

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

Подобное разделение на состояния в будущем позволит нам упростить реализацию процесса ввода, добавив состояние пользователя "Вводит Город", при котором бот перестанет реагировать на прочие раздражители до тех пор, пока пользователь не завершит или не отменит процесс ввода.

Реализация меню

Меню (из *.PageNavs), падающее пользователю по команде '/start' будет состоять из 2 кнопок: навигационная кнопка '> Избранное' и активационная 'Найти'.

Кроме того, для реализации меню нам понадобится добавить в бота сервис тип IMenuManager, для работы которого понадобятся сервис IArgsSerializer (из *.ArgedInteractions) и продвинутая служба доставки сообщений (из *.AdvancedMessages).

await BotBuilder.NewBuilder("your_token")
    .EnablePrivates(privates)
    .AddService<IArgsSerilalizerService>(new DefaultArgsSerilalizerService())
    .AddService<IMenuManager>(GetMenuManager())
    .CustomDelievery(new AdvancedDeliverySystem())
    .Build()
    .Listen();

Реализуем создание менеджера через GetMenuManager()

// Program
private static IMenuManager GetMenuManager()
{
    var mm = new DefaultMenuManager();

    var mainBody = new OutputMessageText("Добро пожаловать!\n\nЧего желаете?");
    var mainMenu = new PageNavMenu();
    var mainPage = new StaticPage("main", "Главная", mainBody, mainMenu);

    var savedBody = new DynamicMessage(u =>
    {
        return new OutputMessageText("_Здесь будут отображаться избранные..._");
    });
    var savedPage = new WidgetPage("saved", "Избранное", savedBody);

    mainMenu.PathTo(savedPage);
    mainMenu.AddAction(StartSearching);

    mm.Define(mainPage);
    mm.Define(savedPage);

    return mm;
}

// Этот коллбэк будет вызывать поиск. Пока что прототип.
private static DefaultCallback StartSearching => new("startSearch", "Найти", Do_SearchAsync);
private static async Task Do_SearchAsync(SignedCallbackUpdate update) { }

Перепишем команду '/start', чтобы она открывала меню

// Program
private static async Task Do_StartAsync(SignedMessageTextUpdate update)
{
    var mm = update.Owner.ResolveService<IMenuManager>();

    // Получаем определённую страницу по id
    // ...StaticPage( { это id -> } "main", "Главная"...
    var page = mm.GetDefined("main");
    
    await mm.PushPageAsync(page, update);
}

Проверяем. Всё работает. Пробуем перейти на следующую страницу и сталкиваемся с проблемой.

Меню открылось
Меню открылось
Ошибка при попытке перейти к следующей странице.
Ошибка при попытке перейти к следующей странице.

Дело в том, что наш бот не определяет никакого обработчика событий для Callback-ов. И хотя мы не вызывали наш прописанный DefaultCallback StartSearching, можно заметить, что MenuManager также реализован на Callback.

Чтобы исправить это ошибку, создадим воронку для работы с обновлениями типа Callback из приватных чатов. Добавим в неё наш коллбэк и применим MenuManager к нашему обработчику.

static async Task Main(string[] args)
{
    // ...
    // Создаём воронку обработки для CallbackUpdate
    var mm = GetMenuManager();
    var privateCallbacks = new DefaultCallbackHandler()
    {
        CallbackManager = new DefaultActionManager<SignedCallbackUpdate>(),
    };

    // Добавляем наш коллбэк
    privateCallbacks.CallbackManager.AddSafely(StartSearching);

    // Применяем меню менеджер к коллбэк менеджеру (добавляет данные о коллбэках mm в наш менеджер)
    mm.ApplyFor(privateCallbacks.CallbackManager);

    // ...
    ChatDesigner privates = ChatDesigner.NewDesigner()
        .UseUsersManager(new UserManager())
        .UseMessageHandler(privateMessages)
        .UseCallbackHandler(privateCallbacks);  // <- Добавляем
    // ...
    await BotBuilder.NewBuilder("your_token")
        .EnablePrivates(privates)
        .AddService<IArgsSerilalizerService>(new DefaultArgsSerilalizerService())
        .AddService<IMenuManager>(mm)  // <- Обновляем
        .CustomDelievery(new AdvancedDeliverySystem())
        .Build()
        .Listen();
}

Как мы видим, опять не хватает языковых пакетов. Скачиваем их оттуда же и размещаем в указанной папке.

Недостающие языковые пакеты
Недостающие языковые пакеты
Главная страница
Главная страница
Дополнительная страница
Дополнительная страница
Снова главная после "<< Назад"
Снова главная после "<< Назад"

В поисках солнца

Перейдём непосредственно к реализации поисковика погодных данных.

Для реализации данной фичи будем использовать Яндекс.Геокодер и Яндекс.Погода.

Обо всём по порядку.

Первым делом вынесем кое-какие переменные в глобальные значения:

internal class Program
{
    private static readonly bool RequestApi = true;
    private static readonly string GeocoderApiKey = ...;
    private static readonly string WeatherApiKey = ...;

    private static readonly string BotApiKey = ...;

    public static DefaultUserState DefaultState = new(0, "default");
    public static DefaultUserState InputCityState = new(10, "typing");

    static async Task Main(string[] args)
    {
        // ...
    }

    // ...
}

Далее создадим два действия текстового ввода:

  1. Текстовый ввод города

  2. Текстовый ввод "Отмена"

Если Отмену можно создать просто воспользовавшись встроенным DefaultTextInput, указав, "Отмена" в качестве ключа реагирования, то текстовый ввод города придётся писать самостоятельно, поскольку он должен реагировать на любое введённое слово.

// Program
private static DefaultTextInput ExitInput => new("Выйти", Do_ExitInputCityAsync);
private static async Task Do_ExitInputCityAsync(SignedMessageTextUpdate update)
{
    // Просто меняем состояние пользователя на исходное
    if (update.Sender is IStatefulUser stateful)
    {
        stateful.State = DefaultState;

        var message = new OutputMessageText($"Вы больше ничего не вводите.")
        {
            Menu = new ReplyCleaner(),
        };
        await update.Owner.DeliveryService.ReplyToSender(message, update);
    }
}
internal class AnyInput : DefaultBotAction<SignedMessageTextUpdate>
{
    public AnyInput(string anyId, BotInteraction<SignedMessageTextUpdate> action) : base("systemAny." + anyId, action)
    { }

    // Действие должно реагировать на любой ввод, поэтому возвращаем true без проверок.
    public override bool ShouldBeExecutedOn(SignedMessageTextUpdate update) => true;
}
// Program
private static AnyInput InputCity => new("city", Do_InputCityAsync);
private static async Task Do_InputCityAsync(SignedMessageTextUpdate update)
{
    // ...
}

Далее добавим менеджер текстового ввода. Обратите внимание, что мы хотим, чтобы бот реагировал на текст только в случае, когда пользователь находится в состоянии ввода текста. Поэтому вместо обычного ActionManager мы будем использовать StatefulManager (из расширения SKitLs.Bots.Telegram.Stateful), указав для StateSection единственное рабочее состояние - InputCityState.

// async Task Main()
var privateMessages = new DefaultSignedMessageUpdateHandler();
var statefulInputs = new DefaultStatefulManager<SignedMessageTextUpdate>();
var privateTexts = new DefaultSignedMessageTextUpdateHandler
{
    CommandsManager = new DefaultActionManager<SignedMessageTextUpdate>(),
    TextInputManager = statefulInputs,
};
privateTexts.CommandsManager.AddSafely(StartCommand);

// Новая секция состояний
var inputStateSection = new DefaultStateSection<SignedMessageTextUpdate>();
// Разрешаем единственное состояние
inputStateSection.EnableState(InputCityState);
// Определяем действия. Сначала идёт Exit, потому что инче Input, который реагирует на любой ввод,
// будет перехватывать введённое слово, не пропуская его до Exit-а
inputStateSection.AddSafely(ExitInput);
inputStateSection.AddSafely(InputCity);
// Добавляем секцию в менеджер
statefulInputs.AddSectionSafely(inputStateSection);

privateMessages.TextMessageUpdateHandler = privateTexts;

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

Мы же идём дальше.

Зарегистрировав наше приложение на Яндекс, получаем два API ключа:

  1. Для .Геокодера, который позволит превратить название населенного пункта в координаты

  2. И для .Погоды, которая даёт состояние/прогноз погоды по координатам места

После чего отправимся применять их в Do_InputCityAsync - обработчике действия "Город был введён". Ввиду того, что API Яндекса не являются темой нашего разбора просто приведу написанный ChatGPT код с некоторыми комментариями.

// Program
private static AnyInput InputCity => new("city", Do_InputCityAsync);
private static async Task Do_InputCityAsync(SignedMessageTextUpdate update)
{
    string cityName = update.Text;
    var geocoderApiUrl = $"https://geocode-maps.yandex.ru/1.x/?apikey={GeocoderApiKey}&geocode={cityName}&format=json";
    double longitude = double.MaxValue;
    double latitude = double.MaxValue;
    if (RequestApi)
    {
        using (var httpClient = new HttpClient())
        {
            string responseString = await httpClient.GetStringAsync(geocoderApiUrl);
            Console.WriteLine(responseString);
            // Для GeoCodeWrap обратите внимание на GitHub этого решения
            var response = JsonConvert.DeserializeObject<GeoCodeWrap>(responseString);
            cityName = response?.Response?.GeoObjectCollection?.FeatureMember?[0]?.GeoObject?.Name ?? update.Text;
            string? pos = response?.Response?.GeoObjectCollection?.FeatureMember?[0]?.GeoObject?.Point?.pos;
            string[] coordinates = pos?.Split(' ') ?? Array.Empty<string>();

            if (coordinates.Length != 2
                || !double.TryParse(coordinates[0], NumberStyles.Float, CultureInfo.InvariantCulture, out longitude)
                || !double.TryParse(coordinates[1], NumberStyles.Float, CultureInfo.InvariantCulture, out latitude))
            {
                await update.Owner.DeliveryService.ReplyToSender("Не удалось найти искомый город...", update);
                return;
            }
        }
    }

    var resultMessage = $"Погода в запрошенном месте: {cityName} ({latitude.ToString().Replace(',', '.')}, {longitude.ToString().Replace(',', '.')})\n\n";

    if (RequestApi)
    {
        var weatherApiUrl = $"https://api.weather.yandex.ru/v2/informers?" +
            $"lat={latitude.ToString().Replace(',', '.')}&lon={longitude.ToString().Replace(',', '.')}";
        using (HttpClient client = new HttpClient())
        {
            client.DefaultRequestHeaders.Add("X-Yandex-API-Key", WeatherApiKey);

            HttpResponseMessage response = await client.GetAsync(weatherApiUrl);
            string content = await response.Content.ReadAsStringAsync();

            JObject jsonObject = JObject.Parse(content);
            JObject factObject = jsonObject["fact"] as JObject;

            // Получение значений полей
            double temp = (double)factObject["temp"];
            resultMessage += $"Температура: {temp} °C\n";
            double feelsLike = (double)factObject["feels_like"];
            resultMessage += $"Ощущается как: {feelsLike} °C\n";
            //string icon = (string)factObject["icon"];
            double windSpeed = (double)factObject["wind_speed"];
            resultMessage += $"Скорость ветра: {windSpeed} м/с\n";
            int pressureMm = (int)factObject["pressure_mm"];
            resultMessage += $"Давление: {pressureMm} мм рт.ст.\n";
            int humidity = (int)factObject["humidity"];
            resultMessage += $"Влажность: {humidity}%\n";

            string daytime = (string)factObject["daytime"];
            bool polar = (bool)factObject["polar"];
            string season = (string)factObject["season"];
            long obsTime = (long)factObject["obs_time"];
        }
    }

    await update.Owner.DeliveryService.ReplyToSender(resultMessage, update);
}

По сути, принцип работы данного кода прост как лопата: пользователь вводит название населённого пункта. Название отправляется Geocoder-у, который ищет нечто подобное у себя. В случае успеха возвращает координаты. Иначе просто ставим костыль-заглушку а-ля "город не найден". Повторюсь, API - не наша ключевая задача.

Из полученных от Geocoder-а координат составляется запрос к Погоде, которая кидает нам нужную информацию.

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

Всё прекрасно работает. Более того, API Яндекса даже в состоянии исправить случайные опечатки.

Создание избранного

Для реализации избранного во-первых создадим класс с информацией об объекте: координаты и название. И обновим класс пользователя так, чтобы в нём хранились избранные города.

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

internal class GeoCoderInfo
{
    [BotActionArgument(0)]
    public string Name { get; set; } = null!;
    [BotActionArgument(1)]
    public long Longitude { get; set; }
    [BotActionArgument(2)]
    public long Latitude { get; set; }

    public GeoCoderInfo() { }
    public GeoCoderInfo(string name, long longitude, long latitude)
    {
        Name = name;
        Longitude = longitude;
        Latitude = latitude;
    }
  
    public string GetDisplay() => $"Город: {Name} ({Latitude.ToString().Replace(',', '.')}, {Longitude.ToString().Replace(',', '.')})";
}
internal class BotUser : IStatefulUser
{
    public List<GeoCoderInfo> Favs { get; } = new();
    // ...
}

Теперь создадим два коллбэка. Один будет добавлять город в избранное, второй будет его удалять.

// Program
private static BotArgedCallback<GeoCoderInfo> FollowGeocode => new(new LabeledData("В избранное", "FollowGeocode"), Do_FollowGeocodeAsync);
private static async Task Do_FollowGeocodeAsync(GeoCoderInfo args, SignedCallbackUpdate update)
{
    if (update.Sender is BotUser user)
    {
        user.Favs.Add(args);

        // Загружаем текст исходного сообщения и добавляем к нему приписку, что город сохранён.
        var message = new OutputMessageText(update.Message.Text + $"\n\nГород сохранён в избранное!")
        {
            // Кроме того удаляем кнопку, чтобы нельзя было сохранить дважды
            Menu = null,
        };
        await update.Owner.DeliveryService.ReplyToSender(new EditWrapper(message, update.TriggerMessageId), update);
    }
}
// Program
private static BotArgedCallback<IntWrapper> UnfollowGeocode => new(new LabeledData("Удалить", "UnfollowGeocode"), Do_UnfollowGeocodeAsync);
private static async Task Do_UnfollowGeocodeAsync(IntWrapper args, SignedCallbackUpdate update)
{
    if (update.Sender is BotUser user)
    {
        user.Favs.RemoveAt(args.Value);

        // Создаём новое меню с кнопкой выхода
        var menu = new PairedInlineMenu();
        menu.Add("Выйти", update.Owner.ResolveService<IMenuManager>().BackCallback);
        // Создаём новое сообщение со старым текстом и припиской
        var message = new OutputMessageText(update.Message.Text + $"\n\nГород был удалён!")
        {
            // Удаляем меню и добавляем новое с кнопкой выхода
            Menu = menu,
        };
        await update.Owner.DeliveryService.ReplyToSender(new EditWrapper(message, update.TriggerMessageId), update);
    }
}
// Где IntWrapper - это простая class обёртка для struct int
internal class IntWrapper
{
    [BotActionArgument(0)]
    public int Value { get; set; }
  
    public IntWrapper() { }
    public IntWrapper(int value) => Value = value;
}

В данном случае для реализации мы используем специальные классы ArgedActions<>(из SKitLs.Bots.Telegram.ArgedInteractions), позволяющие нам передать в качестве аргумента некоторый класс, который будет автоматический сериализован для отправки на сервер и затем десериализован после возвращения с сервера.

Не забываем добавить данные коллбэки в менеджер в нашем Main()

// async Task Main()
privateCallbacks.CallbackManager.AddSafely(StartSearching);

privateCallbacks.CallbackManager.AddSafely(FollowGeocode);
privateCallbacks.CallbackManager.AddSafely(UnfollowGeocode);

mm.ApplyFor(privateCallbacks.CallbackManager);

Далее "прикрутим" наш коллбэк "Follow" к сообщению с информацией о погоде

// Program
private static async Task Do_InputCityAsync(SignedMessageTextUpdate update)
{
    // ...
    var menu = new PairedInlineMenu()
    {
        Serializer = update.Owner.ResolveService<IArgsSerilalizerService>()
    };
    menu.Add(FollowGeocode, new GeoCodeInfo(cityName, longitude, latitude));
    var message = new OutputMessageText(resultMessage)
    {
        Menu = menu,
    };
    await update.Owner.DeliveryService.ReplyToSender(message, update);
}

Итак, проверим, как это работает

Отображение текущей погоды позволяет сохранить город в избранное.
Отображение текущей погоды позволяет сохранить город в избранное.
После нажатия выходит уведомление, что город был сохранён в избранное.
После нажатия выходит уведомление, что город был сохранён в избранное.

Теперь перепишем наш MenuManager, добавив динамическое отображение каждого из сохранённых городов.

private static IMenuManager GetMenuManager()
{
    // ...
    var savedBody = new DynamicMessage(GetSavedList);
    var savedPage = new WidgetPage("saved", "Избранное", savedBody);
    // ...
}

private static IOutputMessage GetSavedList(ISignedUpdate? update)
{
    var message = "Избранное:\n\n";
    if (update is not null && update.Sender is BotUser user)
    {
        if (user.Favs.Count == 0) message += "Ничего нет";
        foreach (var favorite in user.Favs)
        {
            message += $"- {favorite.Name}\n";
        }
    }
    return new OutputMessageText(message);
}

Как видно, город действительно был сохранён в избранное и, в целом, здесь всё ясно: при входящем обновление (с просьбой отобразить избранное) динамически формируется содержание сообщения, выводя сохранённые города. Однако как быть с меню и попытками открыть конкретный город к просмотру?

Как уже было сказано, за работу с данными отвечает пока ещё не выпущенные решение SKitLs.Bots.Telegram.DataBases. С этим проектом наша задача была бы проще, но сейчас, так как ни одна из реализаций IPageMenu не позволяет динамически изменять своё содержание напрямую, нам придётся реализовать небольшой костыль в виде собственного меню.

internal class SavedFavoriteMenu : IPageMenu
{
    public BotArgedCallback<GeoCoderInfo> OpenCallback { get; private set; }

    public SavedFavoriteMenu(BotArgedCallback<GeoCoderInfo> openCallback)
    {
        OpenCallback = openCallback;
    }

    public IMesMenu Build(IBotPage? previous, IBotPage owner, ISignedUpdate update)
    {
        IMenuManager mm = update.Owner.ResolveService<IMenuManager>();

        var res = new PairedInlineMenu()
        {
            Serializer = update.Owner.ResolveService<IArgsSerilalizerService>(),
        };

        if (update.Sender is BotUser user)
        {
            foreach (var favorite in user.Favs)
            {
                res.Add(favorite.Name, OpenCallback, favorite);
            }
        }

        if (previous != null)
        {
            res.Add("Назад", mm.BackCallback, singleLine: true);
        }

        return res;
    }
}

Где OpenCallback - коллбэк, который должен открывать информацию о нашем с вами объекте.

Добавим созданное меню к нашей странице в IMenuManager GetMenuManager() следующим образом

// GetMenuManager()
var savedPage = new WidgetPage("saved", "Избранное", savedBody, new SavedFavoriteMenu(OpenFollow));

Предварительно определив этот самый OpenFollow коллбэк в классе Program

// Program
private static BotArgedCallback<GeoCoderInfo> OpenFollow => new(new LabeledData("{ Открыть }", "OpenFollow"), Do_OpenFollowAsync);
private static async Task Do_OpenFollowAsync(GeoCoderInfo args, SignedCallbackUpdate update)
{
    if (update.Sender is BotUser user)
    {
        var menu = new PairedInlineMenu();
        // menu.Add(коллбэк: загрузить информацию о погоде с сервера);
        // menu.Add(коллбэк: удалить объект из отслеживаемых);
        menu.Add("Назад", update.Owner.ResolveService<IMenuManager>().BackCallback);
        var message = new OutputMessageText(args.GetDisplay())
        {
            Menu = menu,
        };
        await update.Owner.DeliveryService.ReplyToSender(new EditWrapper(message, update.TriggerMessageId), update);
    }
}

В данный момент, с целью уменьшения нагрузки, при открытии объекта информация о погоде не загружается. Давайте пропишем логику загрузки информации о погоде в отдельный коллбэк, предварительно вынеся логику обращения к API Яндекс Погоды в отдельный метод класса Program

// Отдельный метод получения погоды по координатам
private static async Task<string> GetWeatherInfo(double latitude, double longitude)
{
    var resultMessage = string.Empty;

    var weatherApiUrl = $"https://api.weather.yandex.ru/v2/informers?" +
            $"lat={latitude.ToString().Replace(',', '.')}&lon={longitude.ToString().Replace(',', '.')}";
    using (HttpClient client = new HttpClient())
    {
        client.DefaultRequestHeaders.Add("X-Yandex-API-Key", WeatherApiKey);

        HttpResponseMessage response = await client.GetAsync(weatherApiUrl);
        string content = await response.Content.ReadAsStringAsync();

        JObject jsonObject = JObject.Parse(content);
        JObject factObject = jsonObject["fact"] as JObject;

        // Жёсткий парсинг
        double temp = (double)factObject["temp"];
        resultMessage += $"Температура: {temp} °C\n";
        double feelsLike = (double)factObject["feels_like"];
        resultMessage += $"Ощущается как: {feelsLike} °C\n";
        //string icon = (string)factObject["icon"];
        double windSpeed = (double)factObject["wind_speed"];
        resultMessage += $"Скорость ветра: {windSpeed} м/с\n";
        int pressureMm = (int)factObject["pressure_mm"];
        resultMessage += $"Давление: {pressureMm} мм рт.ст.\n";
        int humidity = (int)factObject["humidity"];
        resultMessage += $"Влажность: {humidity}%\n";

        string daytime = (string)factObject["daytime"];
        bool polar = (bool)factObject["polar"];
        string season = (string)factObject["season"];
        long obsTime = (long)factObject["obs_time"];
    }

    return resultMessage;
}

// А также обновим метод получения информация
private static async Task Do_InputCityAsync(SignedMessageTextUpdate update)
{
    // ...
    if (RequestApi)
    {
        resultMessage += await GetWeatherInfo(latitude, longitude);
    }
    // ...
}
// Program
private static BotArgedCallback<GeoCoderInfo> LoadWeather => new(new LabeledData("Узнать погоду", "LoadWeather"), Do_LoadWeatherAsync);
private static async Task Do_LoadWeatherAsync(GeoCoderInfo args, SignedCallbackUpdate update)
{
    if (update.Sender is BotUser user)
    {
        var menu = new PairedInlineMenu()
        {
            Serializer = update.Owner.ResolveService<IArgsSerilalizerService>()
        };
        menu.Add(UnfollowGeocode, new IntWrapper(user.Favs.FindIndex(x => x.Name == args.Name)));
        menu.Add("Назад", update.Owner.ResolveService<IMenuManager>().BackCallback);
        var message = new OutputMessageText(update.Message.Text + "\n\n" + await GetWeatherInfo(args.Latitude, args.Longitude))
        {
            Menu = menu,
        };
        await update.Owner.DeliveryService.ReplyToSender(new EditWrapper(message, update.TriggerMessageId), update);
    }
}

И пара финальных штрихов: немного обновим меню в Do_OpenFollowAsync

var menu = new PairedInlineMenu()
{
    Serializer = update.Owner.ResolveService<IArgsSerilalizerService>()
};
menu.Add(UnfollowGeocode, new IntWrapper(user.Favs.FindIndex(x => x.Name == args.Name)));
menu.Add(LoadWeather, args);
menu.Add("Назад", update.Owner.ResolveService<IMenuManager>().BackCallback);

И добавим наши коллбэки в менеджер

// async Task Main()
privateCallbacks.CallbackManager.AddSafely(OpenFollow);
privateCallbacks.CallbackManager.AddSafely(LoadWeather);

Проверяем результаты

Запустим бота командой '/start' (Do_StartAsync). Проверим вкладку "Избранное" (GetSavedList()). Как видно, в данный момент у нас нет сохранённых городов.

Избранное не содержит информации
Избранное не содержит информации

Вернёмся "Назад" (IMenuManager.BackCallback) и перейдём в "Найти" (Do_SearchAsync). Введём парочку городов и получим информацию о погодных условиях в них (Do_InputCityAsync).

Загруженная погода по городам
Загруженная погода по городам

Сохраним города в избранное (Do_FollowGeocodeAsync) и выйдем из процесса ввода городов (Do_ExitInputCityAsync).

Города сохранены в избранное
Города сохранены в избранное

Заново вызовем '/start' и перейдём в "Избранное". Как видно, сохранённые города добавлены в наш список.

Обновлённое Избранное
Обновлённое Избранное

Откроем первый город (Do_OpenFollowAsync) и загрузим информацию о погоде в нём (Do_LoadWeatherAsync).

Открытие карточки города
Открытие карточки города
Загрузка погоды для города
Загрузка погоды для города

Видно, что с загрузкой информации о погоде в городе кнопка "Узнать погоду" удаляется.

Попробуем "Удалить" город из отслеживаемых (Do_UnfollowGeocodeAsync) и вернёмся назад. Видно, что город пропал из Избранного.

Обновлённое избранное
Обновлённое избранное

Также приведу логи консоли

И общую архитектуру бота

Подведём итоги

В данной статье мы рассмотрели практическое решение реализации простого телеграм-бота с помощью каталога решений SKitLs.Bots.Telegram, существенно упрощающих и качественно выводящих на новый уровень процесс создания телеграм ботов на C#.

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

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

Репозиторий с примером, реализованном в ходе данного решения, доступен по ссылке.

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


  1. Zeusina
    01.08.2023 05:43

    Спасибо за интересный туториал. Давно хотел попробовать написать бота в telegram именно на .net.


  1. mikegordan
    01.08.2023 05:43

    Какое то время назад Телеграм ввел контексты и активно их продвигал. Вы не рассматривали вариант их внедрения или эта библиотека не поддерживает их?


    1. Sargeras02 Автор
      01.08.2023 05:43

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