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

Не так давно опубликовал статью об экспресс-создании бота для Telegram на фреймворке SKitLs.Bots.Telegram. С тех пор внутренний состав фреймворка солидно изменился, вместе с тем были выпущены предварительные версии *.BotProcesses и *.DataBases и вторая версия *.Core.

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

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

Обновление WeatherBot

Всё новое - хорошо забытое старое

Перед обновлением на вторую версию вспомним один момент из прошлой статьи. Локализация сообщений отладки, которую в тот раз мы безуспешно пытались сделать строчкой:

BotBuilder.DebugSettings.DebugLanguage = LangKey.RU;

Начиная с обновления v2.0 ядра проекта данный функционал доступен и позволяет локализовать отладку. В данном примере я воссоздал ту же ошибку, что возникла в прошлый раз - убрал один из обработчиков событий. В результате получил ошибку и предупреждение на русском языке:

Локализованная отладка.
Локализованная отладка.

На новые рельсы

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

Во-первых, придётся обновить некоторые неймспейсы. Дело, скорее, чисто механической обработки.

В основном, это касается прототипов.

using SKitLs.Bots.Telegram.ArgedInteractions.Argumenting.Prototype;

using SKitLs.Bots.Telegram.ArgedInteractions.Argumentation.Prototype;

using SKitLs.Bots.Telegram.Core.Prototypes;

using SKitLs.Bots.Telegram.Core.Prototype;

Во-вторых, начиная с версии v1.3 расширение ArgedInteractions обновило название интерфейса IArgsSerializeService (было: IArgsSerilalizerService).

А v1.1 AdvancedMessages позволяет быстрее создавать Inline-меню с сериализатором, автоматически разрешая этот интерфейса через менеджера.

public async Task ActionFromUpdate(..., ISignedUpdate update)
{
  // ...
  //var res = new PairedInlineMenu()
  //{
  //    Serializer = update.Owner.ResolveService<IArgsSerilalizerService>(),
  //};
  var res = new PairedInlineMenu(update.Owner);
}

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

Ну и по мелочи: IApplicant<T> теперь требует метод ApplyTo вместо ApplyFor; вся доставка исправлена с Delievery на Delivery.

Не отходя от кассы

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

Первое. Редактирование сообщений

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

Сообщение под меню.
Сообщение под меню.

Обновим код следующим образом: воспользуемся специальным классом-оболочкой EditWrapper, чтобы редактировать существующее сообщение, а не отправлять новое:

// async Task Do_SearchAsync()
// Удаляем: await update.Owner.DeliveryService.ReplyToSender(message, update);
await update.Owner.DeliveryService.ReplyToSender(new EditWrapper(message, update.TriggerMessageId), update);

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

Второе. Доступ к ITelegramBotClient

Следующий аспект. Визуализация. Фреймворк SKitLs, как уже говорилось, является своеобразной оболочкой вокруг библиотеки Telegram.Bot. Для организации отправки сообщений служат классы-наследники IDeliverySystem. Но как быть, если функционал реализаций по умолчанию не позволяют отправлять что-либо, кроме текстовых сообщений? Да, можно было бы написать свой класс типа WeatherDelivery : IDeliverySystem и прикрутить необходимый функционал, но есть вариант проще.

Например, вместе с погодой в городе, я хочу отправить координаты города в виде геопозиции. На этот случай без фреймворка можно было бы обратиться к сущности ITelegramBotClient из библиотеки Telegram.Bot и вызвать соответствующий метод SendLocationAsync(). Фреймворк SKitLs точно так же позволяет обратиться к данной сущности через свойство Bot класса BotManager (BotManager.Bot).

Отправив сообщение пользователю сохраним сущность отправленного сообщения и, получив Id отправленного сообщения, отправим геопозицию с ответом на отправленное сообщение. Выглядеть всё это будет следующим образом:

// async Task Do_InputCityAsync()
// Удаляем: await update.Owner.DeliveryService.ReplyToSender(message, update);
var resp = await update.Owner.DeliveryService.ReplyToSender(message, update);
await update.Owner.Bot.SendLocationAsync(update.ChatId, latitude, longitude,
                                         replyToMessageId: resp.Message?.MessageId);
Полученная геопозиция. Telegram Desktop.
Полученная геопозиция. Telegram Desktop.
Полученная геопозиция. Telegram iOS.
Полученная геопозиция. Telegram iOS.

Третье. "Message Box"

Последний момент, который хотел бы продемонстрировать - это функционал вызова AnswerCallbackQuery классического Telegram клиента.

Так же, как и в прошлом примере, доступ к этому методу осуществляется через BotManager.Bot, однако для его вызова требуется параметр callbackQueryId. Все существующие касты обновлений, наследованные от ICastedUpdate, предоставляют доступ как к оригинальному Telegram.Bot.Types.Update обновлению, пришедшему с сервера через CastedUpdate.OriginalSource, так и к распакованным и типизированным данным конкретного типа. В данном случае это свойство Telegram.Bot.Types.CallbackQuery Callback обновления типа SignedCallbackUpdate, которое и даёт доступ к Id данного CallbackQuery.

// async Task Do_FollowGeocodeAsync(... SignedCallbackUpdate update)

// Меням этот код
// var message = new OutputMessageText(update.Message.Text +
//      $"\n\nГород сохранён в избранное!")
// {
//     Menu = null,
// };
// await update.Owner.DeliveryService.ReplyToSender(
//     new EditWrapper(message, update.TriggerMessageId), update);

// Удаляем меню сообщения, то есть кнопку "В избранное"
await update.Owner.Bot.EditMessageReplyMarkupAsync(update.ChatId,
    update.TriggerMessageId, null);
// Отвечаем на коллбэк в виде встроенного уведомления
await update.Owner.Bot.AnswerCallbackQueryAsync(update.Callback.Id,
    "Город сохранён в избранное!", showAlert: false);
AnswerCallbackQueryAsync(..., showAlert: false). Telegram Desktop (слева) и iOS (спрва).
AnswerCallbackQueryAsync(..., showAlert: false). Telegram Desktop (слева) и iOS (спрва).

Ещё по мелочи

К теме разговора относится слабо, но также отмечу этот момент: допишем пару методов для преобразования координат из double к красивому виду формата Axx°yy'zz''. В GeoCoderInfo добавим:

// GeoCoderInfo
// Здесь убираем "Город" перед {Name}
public string GetDisplay() => $"{Name} ({BeautyLatitude(Latitude)} {BeautyLongitude(Longitude)})";

public static string BeautyLatitude(double coordinate)
  => $"{(coordinate >= 0 ? "N" : "S")}{BeautyCoordinate(coordinate)}";
public static string BeautyLongitude(double coordinate)
  => $"{(coordinate >= 0 ? "E" : "W")}{BeautyCoordinate(coordinate)}";
public static string BeautyCoordinate(double coordinate)
{
    int degrees = (int)coordinate;
    double minutesAndSeconds = Math.Abs(coordinate - degrees) * 60;
    int minutes = (int)minutesAndSeconds;
    double seconds = (minutesAndSeconds - minutes) * 60;

    return $"{Math.Abs(degrees)}°{minutes:00}'{seconds:00.00}''";
}

И обновим Do_InputCityAsync

// async Task Do_InputCityAsync
var place = new GeoCoderInfo(cityName, longitude, latitude);
var resultMessage = $"Погода в запрошенном месте:\n{place.GetDisplay()}\n\n";
// ...
var resp = await update.Owner.Bot.SendLocationAsync(update.ChatId, latitude, longitude);
var message = new OutputMessageText(resultMessage)
{
    Menu = menu,
    // ReplyToMessageId = resp.MessageId
};
await update.Owner.DeliveryService.ReplyToSender(message, update);
Обновлённый дисплей координат.
Обновлённый дисплей координат.

Промежуточный самап

Эти действия не особо влияют на способности бота, однако позволяют шире увидеть функционал фреймворка. Весь обновлённый функционал доступен в том же репозитории GitHub, в ветви [v1.0]-Updated. Далее в статье мы займёмся непосредственно изучением новых расширений и дополнением кода.

Подготовительные работы

Перед работой откроем менеджер NuGet, включим "Предварительные версии" и скачаем недостающие пакеты: SKitLs.Bots.Telegram.BotProcesses и SKitLs.Bots.Telegram.DataBases.

Итак, в первую очередь обратимся к возможностями проекта *.DataBases и создадим метод сборки нашего менеджера данных (аккурат перед GetMenuManager())

// Program
private static IDataManager GetDataManager()
{
    var dm = new DefaultDataManager(databaseLabel: "Избранное [DM]");
  
    // Здесь будет заполнение
  
    return dm;
}

Создание датасета

Менеджер данных работает с данными типа IBotDataSet, которые несут функции хранения, изменения и отображения массивов отображаемых данных. По умолчанию этот интерфейс реализован в двух классах: BotDataSet<T> и UserContextDataSet<T>.

Оба класса работают с классами, поддерживающими интерфейс IBotDisplayable (where T : class, IBotDisplayable).

А их различия заключаются в реализации метода GetContextSubset(ISignedUpdate): если первый класс возвращает весь объём данных полностью, то второй позволяет из общего набора данных выделить те объекты данных, чей владелец является пользователем, запросившем открытие этого датасета. Поэтому второй класс вводит дополнительное ограничение на данные, с которыми он в состоянии работать: поддержка интерфейса IOwnedData.

Если обобщать, то BotDataSet<T> будет отображать вообще все данные типа <T>, имеющиеся в боте, в то время как UserContextDataSet<T> автоматически сделает выборку данных, относящихся к пользователю, перед открытием.

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

// GetDataManager()
var favorites = new UserContextDataset<GeoCoderInfo>("favorites", dsLabel: "Города");
dm.AddAsync(favorites);

Сразу же получим ошибку: для GeoCoderInfo не хватает двух интерфейсов. Перейдём в соответствующий класс и определим их.

internal class GeoCoderInfo : IBotDisplayable, IOwnedData
{
    public long BotArgId { get; set; }
    public void UpdateId(long id) => BotArgId = id;
    // ...
    public bool IsOwnedBy(long userId) // => ...;

    public string ListDisplay() => Name;
    public string ListLabel() => Name;
    public string FullDisplay(params string[] args) => GetDisplay();
}

Что касается метода IsOwnedBy, использующегося для определения UserContextDataSet, то его можно было бы определить в лоб, с помощью введения свойства long UserOwnerId.

Однако представьте 10000 москвичей, каждый из которых будет хранить в избранном Москву. Это 10000 записей, содержащих идентичную информацию! Крайне неэффективно.

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

public List<long> Owners { get; } = new();
public bool IsOwnedBy(long userId) => Owners.Contains(userId);

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

Интеграция датасета

Теперь необходимо обновить функционал добавления в избранное. Поскольку класс самого пользователя больше не хранит в себе информации о его избранном, а класс геометки хранит информацию о владельцах, напишем следующее:

// async Task Do_FollowGeocodeAsync()
//user.Favs.Add(args);
var geoCodes = update.Owner
  .ResolveService<IDataManager>()
  .GetSet<GeoCoderInfo>();
var code = geoCodes
  .Find(x => x.Longitude ==  args.Longitude && x.Latitude == args.Latitude);
if (code is null)
{
    code = args;
    await geoCodes.AddAsync(code, update);
}
code.Owners.Add(update.Sender.TelegramId);

В этом коде мы с вами получаем сервис менеджера данных, используемый в нашем боте, в котором выполняем поиск датасета с типом данных GeoCoderInfo. Далее выполняем поиск геометки по координатам и добавляем в её владельцев отправителя. Если же такая метка не нашлась, то создаём и добавляем её.

Обновим аргумент коллбэка UnfollowGeocode с IntWrapper на GeoCoderInfo и аналогично обновим логику удаления.

// async Task Do_UnfollowGeocodeAsync(*GeoCoderInfo* args, ...)
//user.Favs.RemoveAt(args.Value);
var geoCodes = update.Owner
  .ResolveService<IDataManager>()
  .GetSet<GeoCoderInfo>();
var code = geoCodes
  .Find(x => x.Longitude == args.Longitude && x.Latitude == args.Latitude);
code?.Owners.Remove(update.Sender.TelegramId);

Обновим логику добавления коллбэка UnfollowGeocode в меню методов Do_OpenFollowAsync и Do_LoadWeatherAsync

// menu.Add(UnfollowGeocode, new IntWrapper(user.Favs.FindIndex(x => x.Name == args.Name)));
menu.Add(UnfollowGeocode, args);

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

// BotUser
public List<GeoCoderInfo> GetFavorites(ICastedUpdate update) => update.Owner
    .ResolveService<IDataManager>()
    .GetSet<GeoCoderInfo>()
    .GetUserSubset(TelegramId);

Этот метод даст нам доступ к избранному пользователя при любом обновлении. В частности, обновим GetSavedList() и SavedFavoriteMenu.Build

// IOutputMessage GetSavedList()
var favs = user.GetFavorites(update);
if (favs.Count == 0) message += "Ничего нет";
foreach (var favorite in favs)
{
    message += $"- {favorite.Name}\n";
}
// SavedFavoriteMenu.Build()
foreach (var favorite in user.GetFavorites(update))
    // ...

Подключение менеджера данных

Теперь, когда функционал подготовлен, необходим подключить менеджер данных к боту и организовать к доступ к сохранённым данным.

// Program.Main()
var dataManager = GetDataManager();
var mm = GetMenuManager(dataManager);
// DataManager требует Stateful Callback
var statefulCallbacks = new DefaultStatefulManager<SignedCallbackUpdate>();
var privateCallbacks = new DefaultCallbackHandler()
{
    CallbackManager = statefulCallbacks,
};


var bot = BotBuilder.NewBuilder(BotApiKey)
    .EnablePrivates(privates)
    .AddService<IArgsSerializeService>(new DefaultArgsSerializeService())
    .AddService<IMenuManager>(mm)
    // Кроме того, понадобится менеджер процессов для DataManager
    .AddService<IProcessManager>(new DefaultProcessManager())
    .AddService<IDataManager>(dataManager)
    .CustomDelivery(new AdvancedDeliverySystem())
    .Build();

bot.Settings.BotLanguage = LangKey.RU;
dataManager.ApplyTo(statefulInputs);
dataManager.ApplyTo(statefulCallbacks);

await bot.Listen();
private static IMenuManager GetMenuManager(IDataManager dm)
{
    // ...
    mainMenu.PathTo(savedPage);
    mainMenu.PathTo(dm.GetRootPage());    // <- Добавить путь к базе данных
    mainMenu.AddAction(StartSearching);
    // ...
    dm.ApplyTo(mm);
    return mm;
}

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

Обновлённый функционал.
Обновлённый функционал.

Финальные штрихи

Настройка IBotDataSet

В целом, всё работает. Но нас с вами не устраивают кнопки "Добавить" (функционал добавления происходит через процесс поиска) и "Изменить" (геометки не редактируются). Кроме того, тем или иным образом нам надо добавить коллбэк "Узнать погоду".

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

Добавить же действие "Узнать погоду" (LoadWeather) можно через метод AddAction() нашего датасета. Единственное различие, которое возникает - метод требует коллбэк с аргументом DtoArg<T>, наш же метод работает с самим T. Обновим сигнатуру коллбэка и метода () и добавим его к доступным действиям датасета.

// IDataManager GetDataManager()
var favorites = new UserContextDataSet<GeoCoderInfo>("favorites", dsLabel: "Города");
favorites.Properties.AllowAdd = false;
favorites.Properties.AllowEdit = false;
favorites.AddAction(LoadWeather);
// В Do_OpenFollowAsync комментим //menu.Add(LoadWeather, args), исходя из того,
//     что в будущем этот функционал урезан в целом.

private static BotArgedCallback<DtoArg<GeoCoderInfo>> LoadWeather => ...
private static async Task Do_LoadWeatherAsync(DtoArg<GeoCoderInfo> args...)
{
    // вместо args получаем доступ к значению аргумента - args.GetValue()
}

По сути, мы сделали то же, о чём я писал ранее - разграничили объект данных GroCodeInfo и GeoCodeArgument с той лишь разницей, что для этих целей использовали универсальный тип DtoArg<T>. По-хорошему, было бы неплохо поменять все коллбэки, введя везде этот аргумент в целях сохранения логики обмена данными.

Но мы просто насладимся результатом проделанной работы.

Применённые к IBotDataSet настройки.
Применённые к IBotDataSet настройки.

Обновление удаления

Вроде бы, всё хорошо. Однако можно заметить, что после загрузки погоды и кнопки "Удалить" пропал крестик. Это связано с тем, что перед нами две принципиально разные кнопки: одна загружена из IDataManager, вторую мы прописывали сами. В принципе, это можно обнаружить, заглянув в логи: интересующие нас действия выделены белым и тх actionId различается.

Логи входящих обновлений
Логи входящих обновлений

Возникает закономерный вопрос: если мы отказываемся от прежних действий (UnfollowGeocode) и переезжаем на рельсы *.DataBases, что же тогда произойдёт при нажатии кнопки удаления?

А в этом случае сбываются самые худшие подозрения: по умолчанию датасет полностью стирает запрошенный объект. Нам же, напротив, необходимо всего лишь изменить данные подписчиков. Что же, чтобы это исправить необходимо переписать метод "Remove" датасета, используемый по умолчанию.

// private static BotArgedCallback<GeoCoderInfo> UnfollowGeocode
//   => new(new LabeledData("Удалить", "UnfollowGeocode"), Do_UnfollowGeocodeAsync);
private static TextInputsProcessBase<GeoCoderInfo> RemoveWithUnfollow
  => new TerminatorProcess<GeoCoderInfo>(IST.Dynamic(), Do_UnfollowGeocodeAsync);
private static async Task Do_UnfollowGeocodeAsync(TextInputsArguments<GeoCoderInfo> args, SignedCallbackUpdate update)
{
    // ...
}

TerminatorProcess - один из процессов, предоставляемых *.BotProcesses. Он является своеобразной связкой между процессами ввода и делегатами действий. Его метод действия прост: сразу после запуска он вызывает своё уничтожение TerminateAsync(), не дожидаясь повторного входящего обновления и сразу же вызывая действие, которое должно произойти в момент его завершения. Однако, как и для всех процессов TextInputsProcessBase<T>, ему необходимы аргументы, обёрнутые в TextInputsArguments<T>.

Что касается внутрянки Do_UnfollowGeocodeAsync, то выглядит это следующим образом.

// Также получаем датасет
var geoCodes = update.Owner.ResolveService<IDataManager>().GetSet<GeoCoderInfo>();

// Проверяем статус выполнения. Если удаление подтверждено, то обновляем ДС
if (args.CompleteStatus == ProcessCompleteStatus.Success)
{
    //user.Favs.RemoveAt(args.Value);
    var code = geoCodes.Find(x => x.Longitude == args.BuildingInstance.Longitude && x.Latitude == args.BuildingInstance.Latitude);
    if (code is not null)
    {
        code.Owners.Remove(update.Sender.TelegramId);
        await geoCodes.UpdateAsync(code, update);
    }
}

// Утилита, позволяющая получить статус из языкового пакета
var resultText = geoCodes.ResolveStatus(args.CompleteStatus, DbActionType.Remove);
var menu = new PairedInlineMenu();
menu.Add("Выйти", update.Owner.ResolveService<IMenuManager>().BackCallback);
var message = new OutputMessageText(update.Message.Text + $"\n\n{resultText}")
{
    Menu = menu,
};
await update.Owner.DeliveryService.ReplyToSender(new EditWrapper(message, update.TriggerMessageId), update);

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

var favorites = new UserContextDataSet<GeoCoderInfo>("favorites", dsLabel: "Города");
favorites.Properties.AllowAdd = false;
favorites.Properties.AllowEdit = false;
favorites.UpdateProcess(RemoveWithUnfollow, DbActionType.Remove);
favorites.AddAction(LoadWeather);
dm.AddAsync(favorites);
// В Do_OpenFollowAsync опять комментим убранный коллбэк
//menu.Add(UnfollowGeocode, args);

// В Do_LoadWeatherAsync придётся отказаться от сохранения информации Pagination
// либо же сменить аргумент на ObjInfoArg
var dm = update.Owner.ResolveService<IDataManager>();
var menu = new PairedInlineMenu(update.Owner);
menu.Add(dm.RemoveExistingCallback,
         new ObjInfoArg(dm.GetSet<GeoCoderInfo>(), args.DataId));
menu.Add("Назад", update.Owner.ResolveService<IMenuManager>().BackCallback);

Проверим работу.

Обновлённый процесс удаления данных.
Обновлённый процесс удаления данных.

Взглянем на данные отладки и убедимся, что город "Москва" не исчез из наших сохранений, однако мы-как-подписчик исчезли из списка.

Датасет содержит "Москву". "Москва" не владеется нами.
Датасет содержит "Москву". "Москва" не владеется нами.

Сохранение данных

Отдельно стоит отметить, что ни IDataManager, ни его реализация DefaultDataManager не предоставляют способы сохранения данных, являясь лишь виртуальным хранилищем, предоставляющим API работы с вашей БД фреймрворку. Поэтому нам также необходимо реаизовать методы загрузки/сохранения данных.

В качестве БД может выступать, например, SQL или любая другая система. Мы же пропишем этот фнкционал через примитивные Serizlize/Deserizlize методы библиотеки Newtonsoft.Json и хранить данные будем в JSON файле.

Методы обращения к БД.
private static readonly object locker = new();
private static Task<List<T>?> LoadFromJson<T>(string dataName)
{
    var filePath = $"resources/database.{dataName}.json";
    if (!Directory.Exists(new FileInfo(filePath).DirectoryName))
        Directory.CreateDirectory(new FileInfo(filePath).DirectoryName!);

    lock (locker)
    {
        List<T>? res = null;
        if (File.Exists(filePath))
        {
            string json = File.ReadAllText(filePath);
            res = JsonConvert.DeserializeObject<List<T>>(json);
        }
        return Task.FromResult(res);
    }
}
private static Task SaveDataToJson<T>(List<T> data, string dataName)
{
    var filePath = $"resources/database.{dataName}.json";
    if (!Directory.Exists(new FileInfo(filePath).DirectoryName))
        Directory.CreateDirectory(new FileInfo(filePath).DirectoryName!);
    lock (locker)
    {
        string json = JsonConvert.SerializeObject(data, Formatting.Indented);
        File.WriteAllText(filePath, json);
    }
    return Task.CompletedTask;
}

Будем собирать датасет на основе данных БД документа и подключим обновление базы данных к событиям изменения датасета.

// IDataManager GetDataManager()
var favsId = "favs";
var favorites = new UserContextDataSet<GeoCoderInfo>(favsId,
    data: LoadFromJson<GeoCoderInfo>(favsId).Result, // <- Считать данные
    dsLabel: "Города");
favorites.Properties.AllowAdd = false;
favorites.Properties.AllowEdit = false;
favorites.UpdateProcess(RemoveWithUnfollow, DbActionType.Remove);
favorites.AddAction(LoadWeather);
favorites.ObjectAdded += (i, u) => SaveDataToJson(favorites.GetAll(), favsId);
favorites.ObjectUpdated += (i, u) => SaveDataToJson(favorites.GetAll(), favsId);
favorites.ObjectRemoved += (i, u) => SaveDataToJson(favorites.GetAll(), favsId);
dm.AddAsync(favorites);

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

Сохранённые данные.
Сохранённые данные.

Здесь же стоит добавить формальную приписку, что, по возможности, надо снять нагрузку с API геокодера и сначала пробовать подгружать данные геометок их нашего датасета. Кроме того, можно хранить последние данные о погоде и обращаться к Я.Погоде только если прогноз устарел.

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

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

Как и обещал, отвечаю на этот вопрос, поставленный в начале статьи. На мой взгляд, использование всех возможностей фреймворка позволит создавать АИС и CRM системы на базе телеграма. Что это и как это есть планирую расписать в следующей статье, подкрепляя доводы примерами конкретного кейса, над которым сейчас идёт работа.

Как и остальные исходники, результаты работы доступны в репозитории GitHub, в ветве "v2.0-Release".


PS: Крик души или Слабонервным не читать

Позволю себе отойти от профиссионального языка в этом небольшом PS.

Вся работа: от написания кода и документации к нему до оформления этих статей - выполняется мною единолично от и до. Это невероятно ресурсоёмкий и энергозатратный процесс.

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

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

Только лишь вместе можно сделать лучше.

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


  1. Platow
    08.08.2023 04:49

    Когда писал ТГ бота немного замучался, не ожидал, что там такая деревянная архитектура и свой фреймворк. Интересная статья


    1. Sargeras02 Автор
      08.08.2023 04:49

      Стараюсь. Спасибо за поддержку! Всегда открыт к любым предложением по дополнениям/расширениям.