Создание Telegram-бота для получения информации о криптовалютном кошельке Dogecoin +12


image
Со времени описания технологии блокчейн в 2008-м году и появления первой реализации в 2009-м (биткоин) по настоящее время создано более тысячи криптовалют. Каждые несколько дней проводятся ICO. Многие занимаются майнингом или игрой на криптовалютных биржах.

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

Так как информация о блокчейне общедоступна, то доступ к ней возможен с помощью веб-сервисов и мобильных приложений. Для мониторинга состояния счетов удобно использовать мультивалютные мобильные приложения. Однако из-за высокой скорости создания новых криптовалют не все разработчики успевают добавлять их поддержку, и пользователь вынужден устанавливать другие приложения с требуемой валютой, что сказывается на удобстве и на занимаемом объёме памяти устройства. Здесь нам на помощь приходит ещё один тренд современности – чат-боты, API управления которыми предоставляют большинство мессенджеров.

Рассмотрим создание чат-бота для Telegram, предоставляющего информацию о счетах такой криптовалюты, как Dogecoin. Dogecoin был представлен в 2013-м году и назван в честь интернет-мема Doge. Часто используется для сбора пожертвований и благотворительности.

Регистрация бота в Telegram


Начнём с регистрации бота в мессенджере. Для этого найдём в Telegram отца всех ботов – BotFather, отправим ему команду «/newbot» и заполним обязательные параметры: отображаемое (DogeWallet) и уникальное имя (DogeWalletBot). После этого BotFather предоставит токен доступа к боту. На этом можно закончить с регистрацией, но для удобства использования введём также описание бота, изображение и список команд, которые будут появляться после ввода символа «/»:

  • setwallet — устанавливает адрес кошелька Dogecoin;
  • balance — возвращает баланс введённого или адреса по умолчанию кошелька DogeCoin;
  • received — возвращает Dogecoin полученные введённым адресом кошелька или адресом по умолчанию;
  • sent — возвращает Dogecoin отправленные указанным адресом кошелька или адресом по умолчанию;
  • qrcode — возвращает QR-код указанного адреса или адреса по умолчанию;
  • report — возвращает отчёт о транзакциях указанного адреса или адреса по умолчанию;
  • rate – возвращает текущий курс Dogecoin в долларах США и биткоинах
  • help — показывает все доступные команды.

Предустановки


Теперь, когда бот зарегистрирован и определён основной функционал, можно приступать к выбору платформы разработки. Писать бота будем на Microsoft Bot Framework, т.к. он предоставляет широкие возможности по работе с диалогами и большой набор поддерживаемых каналов общения (что предоставляет возможность быстрого запуска бота в другом мессенджере почти без переписывания кода), в качестве языка выбираем C# (также возможно использование Node.js).

Для начала работы требуется Visual Studio 2015 или выше, шаблоны бота Bot Application, Bot Controller, Bot Dialog и Bot Framework Emulator – подробнее процедура установки описана в документации Microsoft Bot Framework.

Создание бота, обработка команд


Создаём проект из шаблона «Bot Application».

image

В структуре проекта можно наблюдать такие классы, как MessagesController и RootDialog. Входной точкой приложения, принимающей все входящие сообщения, является метод Post класса MessagesController. Он содержит следующий код:

public async Task<HttpResponseMessage> Post([FromBody]Activity activity)
        {
            if (activity.Type == ActivityTypes.Message)
            {
                await Conversation.SendAsync(activity, () => new Dialogs.RootDialog());
            }
            else
            {
                HandleSystemMessage(activity);
            }
            var response = Request.CreateResponse(HttpStatusCode.OK);
            return response;
        } 

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

Класс RootDialog, как и все диалоги, реализует интерфейс IDialog и должен содержать метод StartAsync. Первое сообщение, полученное ботом, поступает в этот метод. По умолчанию он содержит вызов context.Wait(MessageReceivedAsync) – то есть устанавливается метод, обрабатывающий следующее сообщение (но, так как до этого не было установлено ни одного обработчика полученного сообщения, то вызов MessageReceivedAsync произойдёт сразу после StartAsync).

Изменим метод MessageReceivedAsync так, чтобы вместо отправки полученного сообщения назад пользователю, он обрабатывал команды, определённые ранее.

private async Task MessageReceivedAsync(IDialogContext context, IAwaitable<object> result)
        {
            var activity = await result as Activity;

            // if "/setwallet" command
            if (activity.Text == "/setwallet")
            {
                context.Call(new SetWalletDialog(), SetWalletDialogResumeAfter);
            }
            // if "/setwallet [address]" command
            else if (activity.Text.Contains("/setwallet"))
            {
                var forvardedMsg = context.MakeMessage();
                forvardedMsg.Text = activity.Text;
                await context.Forward(new SetWalletDialog(), SetWalletDialogResumeAfter, forvardedMsg, CancellationToken.None);
            }
            // if "/balance [address]" command
            else if (activity.Text.Contains("/balance"))
            {
                var forvardedMsg = context.MakeMessage();
                forvardedMsg.Text = activity.Text;
                await context.Forward(new GetBalanceDialog(), GetBalanceDialogResumeAfter, forvardedMsg, CancellationToken.None);
            }
            else
            {
                if (activity.Text == "/start") //start conversation
                    await GreetUser(context, result);
                else if (activity.Text == "/help") //show help
                    await ShowHelp(context);

                context.Wait(MessageReceivedAsync);
            } 
        }

Первой обрабатывается команда "/setwallet": если введена только эта команда без указания адреса, то вызывается диалог SetWalletDialog с помощью метода context.Call. Он принимает такие параметры, как новый вызываемый диалог и функция, вызываемая по завершении вызванного диалога (SetWalletDialogResumeAfter). Тут стоит отметить, что Microsoft Bot Framework использует такую структуру, как стек диалогов – то есть первым вызывается RootDialog, который в свою очередь вызывает SetWalletDialog, помещая его в верх стека. Пока он находится на вершине, все сообщения будут поступать к нему (не считая глобальных диалогов, способных перехватывать управление при необходимости). При создании нового диалога сразу вызывается метод StartAsync класса SetWalletDialog:

        public async Task StartAsync(IDialogContext context)
        {
            var msg = context.Activity.AsMessageActivity();
            if (msg.Text == "/setwallet")
                await context.PostAsync("Enter DogeCoin wallet address, please!");
            context.Wait(MessageReceivedAsync);
        }

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

Если поступает команда, содержащая также и адрес ("/setwallet [address]"), то новый диалог вызывается с помощью метода Forward. Этот метод отличается от Call тем, что передаёт сообщение далее в вызываемый диалог и после завершения метода StartAsync не происходит ожидание ввода, а сообщение сразу передаётся в метод MessageReceivedAsync класса SetWalletDialog. Рассмотрим этот метод.


private async Task MessageReceivedAsync(IDialogContext context, IAwaitable<IMessageActivity> result)
        {
            var message = await result;
            string address = "";
            if ((message.Text != null) && (message.Text.Trim().Length > 0))
            {
                if (message.Text.Contains(" ")) //if "/setwallet [address]" command
                {
                    address = message.Text.Replace("/setwallet ", "").Trim();
                }
                else
                    address = message.Text;
                try
                {
                    var balance = await Client.GetBalanceAsync(address);
                    if (balance.Success == 1)
                    {
                        context.UserData.SetValue("wallet", address);
                        context.Done(address);
                    }
                    else
                        await ProcessErrors(context);
                }
                catch(Exception ex)
                {
                    await ProcessErrors(context);
                }
            }
        }

Анализируется сообщение, если оно содержит пробел (то есть и команду, и адрес), то этот адрес выделяется и сохраняется в переменной. Этот вариант срабатывает в случае вызова диалога методом Forward. Если сообщение не содержит пробела (предполагается что введён адрес после сообщения бота о предложении его ввести), то адрес сохраняется в переменную без обработки. Этот случай возникает при создании диалога методом Call. Далее проверяется адрес на существование, путём попытки получить его баланс. Если это удаётся, то адрес сохраняется во внутреннее хранилище MS Bot Framework (context.UserData.SetValue(«wallet», address)) и диалог SetWalletDialog завершает работу, удаляется из стека (context.Done(address)) и передаёт родительскому диалогу адрес.

В случае ошибки (неправильный адрес), происходит обработка ошибок в методе ProcessErrors:


private async Task ProcessErrors(IDialogContext context)
        {
            --attempts;
            if (attempts > 0)
            {
                await context.PostAsync(ExceptionMessage);
                context.Wait(MessageReceivedAsync);
            }
            else
            {
                /* Fails the current dialog, removes it from the dialog stack, and returns the exception to the 
                    parent/calling dialog. */
                context.Fail(new TooManyAttemptsException(ExceptionFinalMessage));
            }
        }

Пользователю даётся несколько попыток ввести верный адрес, затем диалог завершается с сообщением об ошибке (context.Fail(new TooManyAttemptsException(ExceptionFinalMessage))) и управление передаётся RootDialog.

После завершения дочернего диалога в родительском вызывается метод [DialogClassName]ResumeAfter, где происходит дальнейшая обработка результата дочернего диалога и вызывается метод context.Wait(MessageReceivedAsync) для указания получателя следующего сообщения.

Подобным образом обрабатываются и остальные команды с одним лишь отличием – они всегда передаются методом Forward, так как не требуют ввода уточняющего сообщения (в случае ввода команды без аргумента, адрес берётся из хранилища (context.UserData.TryGetValue(«wallet», out address)).

Остановимся на получении данных для вывода ботом, и в частности на представленном ранее методе Client.GetBalanceAsync. Client – статический класс, предоставляющий доступ к данным о криптовалютных кошельках. Он содержит методы, обращающиеся к API соответствующих сервисов. В данном проекте используются API таких проектов, как dogechain.info и chain.so. Рассмотрим метод GetBalanceAsync:


public static async Task<BalanceEntity> GetBalanceAsync(string address)
        {
            return await GetAsync<BalanceEntity>($"address/balance/{address}");
 }


Здесь вызывается обобщённый метод получения данных, дополняя запрос и указывая тип возвращаемых данных. Тип возвращаемых данных описывается в простом POCO-классе BalanceEntity:
public class BalanceEntity
    {
        public string Balance { get; set; }
        public int Success { get; set; }
    }

Рассмотрим метод GetAsync:


public static async Task<T> GetAsync<T>(string path)
        {
            InitClient();
            T entity = default(T);
            HttpResponseMessage response = await client.GetAsync(path);
            if (response.IsSuccessStatusCode)
            {
                var jsonString = await response.Content.ReadAsStringAsync();
                entity = JsonConvert.DeserializeObject<T>(jsonString);
            }
            return entity;
        }

Данный метод инициализирует новый экземпляр класса HttpClient в методе InitClient:


public static void InitClient() => InitClient(WebApiHost);
        public static void InitClient(string webApiHost)
        {
            WebApiHost = webApiHost;
            if (client != null && !string.IsNullOrEmpty(WebApiHost) && client.BaseAddress.AbsoluteUri == WebApiHost)
                return;
            client = new HttpClient
            {
                BaseAddress = new Uri(WebApiHost)
            };
            client.DefaultRequestHeaders.Accept.Clear();
            client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
        }

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

После реализации основных команд можно проверить работоспособность бота. Для этого воспользуемся эмулятором, скаченным ранее. Вводим адрес, на котором запущен бот и жмём CONNECT. Вводим команду «/setwallet» — бот говорит нам ввести адрес кошелька. Вводим.

image

И бот сообщает, что успешно сохранил его.

image

Проверим баланс командой «/balance» и узнаем, что на счету лежит чуть более 357-и собак: «Balance of D6VDAHdzDuuUxorDN1dRZVudu2PfxZvUNp address is: 357.91600317 DogeCoin’s».

Добавим приветствие, посылаемое пользователю при первом обращении к боту. Так как в качестве платформы для коммуникации мы используем Telegram, то первое сообщение, получаемое ботом будет «/start». Обработаем его, а также команду «/help» в MessageReceivedAsync класса RootDialog:


if (activity.Text == "/start") //start conversation
                    await GreetUser(context, result);
                else if (activity.Text == "/help") //show help
                    await ShowHelp(context);

                context.Wait(MessageReceivedAsync);

При получении команды вызываем соответствующие методы, а затем устанавливаем метод для обработки следующего полученного сообщения. Метод GreetUser отправляет сообщение приветствия и показывает справку о командах, ShowHelp – только справку.

private async Task GreetUser(IDialogContext context, IAwaitable<object> result)
        {
            await SendGreetMessage(context);
            await ShowHelp(context);
        }

В методе SendGreetMessage происходит создание сообщения приветствия и прикрепляется изображение Doge:


        private async Task SendGreetMessage(IDialogContext context)
        {
            var qrMsg = context.MakeMessage();
            qrMsg.Text = "Welcome, Young Shibe!\r\n";
            qrMsg.Attachments.Add(new Attachment()
            {
                ContentUrl = "http://www.stickpng.com/assets/images/5845e608fb0b0755fa99d7e7.png",
                ContentType = "image/png",
				Name = " "
            });
            await context.PostAsync(qrMsg);
        }

Новое вложение (Attachment) содержит такие параметры, как ContentUrl – путь к изображению, ContentType – тип вложения и Name – имя.

Регистрация бота в MS Bot Framework


После создания бота и отладки локально с помощью эмулятора можно приступать к развёртыванию на сервере и регистрации в Azure (ранее регистрация производилась на портале botframework.com, но с недавнего времени производится миграция в Azure и рекомендуется создавать новых ботов именно там). Публикуем нашего бота на Windows-хостинге (к сожалению, Bot Builder не поддерживает Core в текущей версии) или в Azure. После этого регистрируем бота. Для этого переходим в portal.azure.com, жмём кнопку создания нового ресурса (New) и вводим в поиске «bot». Из списка выбираем Bot Channels Registration и жмём «Create».

image

Заполняем поля, вводим в качестве Messaging Endpoint адрес нашего развёрнутого бота, добавив далее «/api/messages» — dogewalletbot.azurewebsites.net/api/messages.

image

После создания «Bot Channels Registration» заходим в него и во вкладке «Settings» ищем Microsoft App ID и расположенную рядом ссылку Manage. Переходим по ней на apps.dev.microsoft.com и жмём кнопку «Создать новый пароль». Запоминаем Microsoft App ID и пароль (MicrosoftAppPassword) и вводим их в Web.config нашего бота в разделе appSettings. После этого связь бота с каналами должна работать. Проверим в созданном ранее сервисе Bot Channels Registration в разделе «Test in Web Chat»:

image

Далее подключим канал для соединения с Telegram. Жмём Channels, выбираем Telegram и вводим токен, предоставленный BotFather. Заходим в Telegram, добавляем бота и жмём «Start».

image

Бот, получив команду «/start» отвечает приветствием и справкой. Также происходит и обработка остальных команд.

Создание отчёта


Реализуем отправку отчёта о входящих на адрес кошелька транзакциях в виде списка «id транзакции, значение» в формате PDF. Для этого воспользуемся генератором отчётов FastReport.Net и библиотекой работы с Telegram API (Telegram.Bot) для отправки pdf на сервера Telegram.

Данные о транзакциях будем получать по адресу chain.so/api/v2/get_tx_received/DOGE[address]. Ответ будем преобразовывать в класс следующей структуры:


public class ReceivedTransactionsResponse
    {
        public string Status { get; set; }
        public ReceivedTransactionsEntity Data { get; set; }
    }
    public class ReceivedTransactionsEntity
    {
        public string Address { get; set; }
        public List<ReceivedTransaction> Txs { get; set; }
    }
    public class ReceivedTransaction
    {
        public string Txid { get; set; }
        public string Value { get; set; }
        public string Confirmations { get; set; } 
    }

Метод получения данных о транзакциях класса Client выглядит следующим образом:

public static async Task<List<ReceivedTransaction>> GetReceivedTransactions(string address)
        {
            WebApiHost = WebApiHostChainSo;
            var trs = await GetAsync<ReceivedTransactionsResponse>($"get_tx_received/DOGE/{address}");
            WebApiHost = WebApiHostDogechain;
            return trs?.Data?.Txs;
        }

Он переопределяет URL вызова API вначале метода и возвращает обратно перед завершением.

После получения списка транзакций в классе ReportDialog вызывается метод SendReport:


private async Task SendReport(IDialogContext context, string address, List<ReceivedTransaction> transactions)
        {
            Reporter repr = new Reporter();
            if (context.Activity.ChannelId != "telegram")
                return;

            using (MemoryStream pdfReport = repr.GetReceivedTransactionsPdf(address, transactions))
            {
                TelegramBotClient client = new TelegramBotClient("telegram_bot_token");
                var me = await client.GetMeAsync();

                var chatId = context.Activity.From.Id;

                await client.SendDocumentAsync(chatId, new FileToSend("Received transactions.pdf", pdfReport), "First 100 received transactions.");
            }
        }

В нём, в свою очередь, создаётся отчёт в классе Reporter. Затем создаётся TelegramBotClient и отправляется pdf-отчёт.

Рассмотрим метод GetReceivedTransactionsPdf класса Reporter:


public MemoryStream GetReceivedTransactionsPdf(string address, List<ReceivedTransaction> transactions, string afterTXID = null)
        {
            string appData = HostingEnvironment.MapPath("~/App_Data/");
            Config.WebMode = true;
            Report = new Report();
            Report.Load(appData + "TransactionsReport.frx");
            Report.RegisterData(transactions, "txs");
            Report.GetDataSource("txs").Enabled = true;
            (Report.FindObject("Data1") as DataBand).DataSource = Report.GetDataSource("txs");
            Report.Prepare();
            PDFExport pdf = new PDFExport();
            MemoryStream exportStream = new MemoryStream();
            Report.Export(pdf, exportStream);
            exportStream.Position = 0;
            return exportStream;
 }

В начале строкой Config.WebMode = true устанавливается режим работы FastReport.Net, предотвращающий появление индикаторов загрузки и другой графики, не нужной в веб-режиме. Далее загружается подготовленный ранее отчёт из папки App_Data. Отчёт содержит заголовок отчёта, страницы и DataBand. На бэнде данных расположены два текстовых объекта (TextObject) для отображения номера транзакции и её значения.

image

После загрузки отчёта происходит подключение данных с помощью метода Report.RegisterData, включение подключенного источника данных и назначение его бэнду Data. Затем производится экспорт в PDF и возвращается Stream, содержащий отчёт.

Проверим работу команды. Отправим в Telegram сообщение — /report DRapidDiBYggT1zdrELnVhNDqyAHn89cRi и получим в ответ сообщение.

image

Откроем файл и увидим список входящих транзакций и их значения.

image

Заключение


Исходный код проекта доступен на GitHub.

В настоящее время для полноценного использования MS Bot Framework требуется полный .Net Framework и Windows-хостинг, что несколько удорожает обслуживание ботов, однако в следующей версии планируется добавление поддержки Core для Microsoft.Bot.Builder. К сожалению, дата релиза пока не известна.

Полезные ссылки


  1. Документация по созданию ботов в Telegram
  2. Документация по MS Bot Framework
  3. .NET Client for Telegram Bot API на GitHub
  4. Документация .NET Client for Telegram Bot API
  5. Описание Dogechain.info API
  6. Описание chain.so API




К сожалению, не доступен сервер mySQL