Рецепт разработки бота под Telegram



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

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

    Статья может быть интересна как новичкам в программировании — увидеть, сколько препятствий стоят на пути у готового продукта, так и более продвинутым специалистам — где-то посмеяться, где-то поплакать, где-то написать комментарий «жизненно».

    Преамбула


    И так, что же может сделать один программист за 4 дня?

    Написать модуль, написать один экран iOS приложения, написать 15% сложного бота под Telegram, поднять или опустить MMR в Dota2 или ранг в Overwatch. Может провести выходные на природе, сходить в тренажерный зал, поплавать в бассейне, написать 500-1000 строк кода в приложении для клиентов, которым никогда и никто, кроме друзей и родственников заказчика, пользоваться не будет. Может смотаться в Кремниевую Долину на последние деньги, посмотреть пару-тройку сериалов, познакомиться с уймой людей на одной-двух конференциях, повтыкать в документацию Ассемблера.

    А что, если я скажу, что все это — полная чушь? Что, если я вам открою секрет: все не так-то просто. Время — штука относительная, можно мерить ее разными величинами и коэффициентами; мой любимый критерий — отношение времени к пользе. Что, если я вам скажу, что программист может запустить новый продукт на рынок за 4 дня? Заинтригованы? — Читайте дальше.

    Мотивация


    К нам в сообщество программистов в Telegram недавно добавился «наш собственный ивангай» и начал дико сорить направо и налево голосовыми сообщениями (кто его знает, вдруг парню просто сложно писать пальцами по клавиатуре). Естественно, людям надоело слушать по два-три часа в сутки сообщения, чтобы вникнуть в суть беседы — и тут кто-то предложил создать бота, который автоматически переводил бы все войсы в текст. Это именно тот момент, когда загораются глаза.

    Перевод голоса в текст


    Я не знал, сколько времени займет у меня разработка подобного бота, но уже имел достаточно опыта за плечами с ботами из нашего теплого уютного чатика и фриланс-биржой в Telegram с открытым исходным кодом. Не долго думая, сел за разработку — этап подключения модулей Telegam Bot API прошел, как по маслу. Что дальше? Модуль распознавания речи — гуглим «voice recognition api» и получаем среди первых ссылок список лучших сервисов. Во главе стоит Google Speech API в бета-версии — его и возьмем.

    Получаем все голосовые сообщения ботом и как-то направляем в сторону Google. Но, вот незадача: почти все npm модули работы с этим сервисом либо устарели, либо банально не работают. Пробуем использовать встроенное API под Node.js, написанное специалистами Google — работает. Что там по аутентификации? Испробовав уйму вариантов из документации от Google — которая, кстати говоря, тоже 50 на 50 устарела или не работает — и вот оно, ключики подошли.

    К слову, вот сам код, который я использовал:

    Жми меня!
    const Speech = require('@google-cloud/speech');
    
    const speech = new Speech({
      projectId: 'voicy-151205',
      credentials: require('path/to/certificate/file.json')
    });
    
    speech.startRecognition(filepath, {
      'encoding': 'LINEAR16',
      'sampleRate': 16000,
      'languageCode': 'en-US',
    })
      .then((results) => {
        const operation = results[0];
        return operation.promise();
      })
      .then((transcription) => {
        console.log(transcription[0]);
      })

    Аудио форматы и ограничения Google


    Что там по отправке войсов к Google? Ага, нужно получить аудиофайлы из Telegram и отправлять их на сервера Speech API — пробуем, не работает. В чем дело? Формат .oga, обычный для Telegram, не принимается — нужно декодировать. Что у нас есть для конвертации медиа? Конечно же, ffmpeg, знакомый еще с раннего детства (спасибо папе, который когда-то давно заставил меня в нем разбираться). Как его подключить к ноде? Опа! Есть npm модуль, заточенный специально под это. В какой формат нужно конвертировать .oga файлы? Оказалось, Google принимает .flac — конвертируем, пробуем, все принимается, Google отвечает текстом, успех.

    Но, не тут-то было! Google не переводит в текст файлы длиннее 60 секунд, если не загружать их на Google Cloud Storage сервис. Что же такое? Сразу же пробуем модуль, написанный программистами Google, даже не смотрим в сторону устаревших npm готовых решений. Отлично, файлы загружаются, обрабатываются сервисом, возвращается текст. Кстати, странное у Google понятие времени — аудиофайлы длинной в 30 секунд, почему-то, определяются, как 60-ти секундные. Ничего страшного — пробуем файл длиннее 30 секунд и наступаем на очередные грабли — Google принимает длинные файлы только в кодировке «LINEAR16».

    Что еще за «LINEAR16»? Ищем в документации ffmpeg — ничего подобного нет. Отлично, как так? Работаем дальше. Ищем, что за зверь этот формат или кодек — оказывается, это «16 bit signed little endian» данные. Хорошо, что я прочитал пару книжек по Computer Science и знаю, что такое «16 bit» и почему это «little endian». Ищем, что же из себя представляет этот формат в ffmpeg — ага: «s16le»! Пробуем конвертировать — получается, Google принимает и отвечает текстом, «Mission accomplished».

    Осторожно: ниже код, который помог мне с конвертацией (нужно предустановить ffmpeg на машину)!

    Жми меня!
    const ffmpeg = require('fluent-ffmpeg');
    const temp = require('temp');
    
    ffmpeg.ffprobe(filepath, (err, info) => {
      const fileSize = info.format.duration;
      const output = temp.path({ suffix: '.flac' });
    
      ffmpeg()
        .on('end', () => console.log(output))
        .input(filepath)
        .setStartTime(0)
        .duration(fileSize)
        .output(output)
        .audioFrequency(16000)
        .toFormat('s16le')
        .run();
    });

    Монетизация


    Но, что это такое? Какие $2 за использование Speech API? Они там в Google совсем с дубу рухнули? Как это мы использовали более двух часов перевода голоса в текст? Так совсем не пойдет — добавят моего бота в 100-1000 чатов, и что мне потом делать? С завтраков сэкономить на поддержку бота уже не получится — не тот масштаб. Нужно как-то прикручивать оплату. Пускай, будет 600 бесплатных секунд у каждого чата, а дальше будем запрашивать покрытие стоимости Google Speech API.

    Как прикрутить оплату к боту в Telegram? Четких инструкций, как и инструментов монетизации в Telegram еще не завезли — нужно как-то решать вопрос вовне. Какой платежный сервис использовать? Так-так-так, недавно читал про парня, который стал самым молодым миллиардером — тот создал свой платежный сервис. Гуглим, видим — Stripe, его и используем. Что это у нас? У них есть удобный Checkout — но стандартную форму мы, конечно, использовать не будем, сделаем свою.

    Фронтенд


    Куда смотреть, чтобы делать интерактивные странички? Я же iOS программист, недавно окунулся в серверную разработку — так, ноги слегка сполоснуть — а тут фронтенд подоспел, что же за напасть? Гуглим быстренько, какие технологии используются для создания интерактивных сайтов. Angular 2, jQuery, Vanila.js — самым простым выглядит jQuery, его и возьмем — тем более, я с ним когда-то давно уже развлекался. Гуглим YouTube уроки по jQuery — один трешак по 2-3 часа, будем разбираться по ходу дела, туториалам и ответам на Stack Overflow.

    Быстренько рисуем структуру сайта — лого, форму, пару строк текста и кнопку. Как это сделать? Что-то я слышал про Bootstrap. Гуглим, проходим пару уроков по Bootstrap 3 — еще гуглим, прикручиваем картинку, форму, кнопочку — вроде как, даже адаптивно получилось. Меняем фон сайта с белого на что-то более креативное (сероватый слегка), вот и сайтик готов.

    Время jQuery! Решаем вынести оплату в отдельный проект — запускаем в командной строке «express payments», получаем проект. Отлично, куда заносить скрипты? Как, прямо в сайт? Что-то не так — и вправду, можно вынести их в отдельный файл, этим и займемся. Прикручиваем обработку ошибок на отдельный лейбл, проверяем форму Stripe (благо, они дают весь нужный пользовательский интерфейс в Checkout модуле), прикручиваем наш новый проект к той же базе данных, проверяем оплату — проходит, все работает. По пути, конечно, ввиду отсутствия привычным фронтенд-разработчикам инструментов, мириады раз перезагружаем веб-страничку вручную.

    Вот тут файлики, которые у меня получились (осторожно, не очень чистый код) c моими небольшими комментариями:

    Жми меня!
    index.hjs
    <!DOCTYPE html>
    <html>
      <head>
        <title>Voicy payments</title>
        <!--Подключаем Bootstrap, jQuery, наши скрипты в global.js, наши стили в style.css и Stripe-->
        <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
        <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
        <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>
        <script src="/javascripts/global.js"></script>
        <script src="https://checkout.stripe.com/checkout.js"></script>
        <link rel='stylesheet' href='/stylesheets/style.css' />
      </head>
      <body>
        <!--Добавляем картинку-лого, выводим пользователю id чата, сколько секунд осталось и цену-->
        <img src="/images/logo.png" alt="Voicy" class="center-block">
        <h1 class="center-block text-center">Chat ID:</h1>
        <h1 class="center-block text-center">{{ chatId }}</h1>
        <p class="text-center">{{ seconds }} seconds are left in this chat.</p>
        <p class="text-center">You can buy more seconds below.</p>
        <p class="text-center"><b>$0.4 per 200 seconds</b></p>
        <form>
          <div class="center">
            <!--Блок формы — текстовое поле, лейблы и сама кнопка-->
            <form class="form-inline" id="buy">
              <div class="form-group">
                <!--Возможно, без этой формы можно было обойтись, но я отдельно сохранял chatId в невидимое поле-->
                <input type="hidden" name="chatId" value="{{ chatId }}">
                <!--Текстовое поле, куда пользователь введет, сколько секунд он покупает-->
                <input type="number" class="form-control" name="numberOfSeconds" placeholder="Enter number of seconds">
                <!--Пустые лейблы — info, error и success, которые мы сможем потом заполнить из global.js-->
                <small id="infoLabel" class="form-text text-info"></small>
                <small id="errorLabel" class="form-text text-danger"></small>
                <small id="successLabel" class="form-text text-success"></small>
              </div>
              <button type="submit" class="btn btn-primary center-block" id="buyButton">Buy</button>
            </form>
          </form>
        </form>
      </body>
    </html>
    

    global.js
    // Здесь начинается магия
    $(document).ready(function() {
      // Глобальные переменные — это плохо, но здесь сойдет
      var chatId;
      var amount;
      // Создаем обработчик от Stripe Checkout
      var handler = StripeCheckout.configure({
        key: '***',
        image: 'https://pay.voicybot.com/images/stripe.png',
        locale: 'auto',
        // alipay: true, // Может, подключу как-нибудь AliPay
        // bitcoin: true, // Биткоины не получается использовать — у меня нет верифицированного US аккаунта банковского
        closed: function() {
          // Очищаем все, кроме info, когда закрываем stripe
          $("#successLabel").empty();
          $("#errorLabel").empty();
        },
        token: function(token) {
          // Когда кредитка введена пользователем, мы получаем token и должны передать его на наш сервер
          // Попутно чистим лейблы (знаю, код повторяется — но здесь и так сойдет)
          $("#infoLabel").empty();
          $("#successLabel").empty();
          $("#errorLabel").empty();
          // Уведомим пользователя, что оплата идет
          $("#infoLabel").append('Processing payment on Voicy servers...');
          // Проводим непосредственно оплату, посылаем запрос в index.js
          $.ajax({
            type: 'POST',
            url: 'buy',
            data: { 'token': token.id, 'chatId': chatId, 'amount': amount },
            dataType: 'json',
            encode: true
          })
          .done(function(data) {
            // Логика простая: все ок — показываем сообщение "Ок"; ошибка — показываем ошибку
            if (data['error']) {
              $("#infoLabel").empty();
              $("#successLabel").empty();
              $("#errorLabel").empty();
              $("#errorLabel").append(data['error']);
            } else {
              $("#infoLabel").empty();
              $("#successLabel").empty();
              $("#errorLabel").empty();
              $("#successLabel").append('Thank you for the payment!');
            }
          });
        }
      });
    
      // Закроем Stripe, если он хочет закрыться, очистим лейблы попутно
      window.addEventListener('popstate', function() {
        $("#infoLabel").empty();
        $("#successLabel").empty();
        $("#errorLabel").empty();
        handler.close();
      });
    
      // Обрабатываем форму — это происходит по нажатию на "Buy"
      $('form').submit(function(event) {
        event.preventDefault(); // Без этой строчки страница перезагрузится
        var seconds = $('input[name=numberOfSeconds]').val(); // Получаем секунды из текстового поля
        chatId = $('input[name=chatId]').val(); // Получаем chat id из невидимого поля
        // Проводим простую валидацию — если юзер покупает меньше 200 секунд, высвечиваем ошибку
        if (!seconds || seconds < 200) {
          $("#infoLabel").empty();
          $("#successLabel").empty();
          $("#errorLabel").empty();
          $("#errorLabel").append('Please purchase at least 200 seconds');
        } else {
          // Если все ок — считаем ценник в центах...
          var purch = seconds * 0.002 * 100;
          amount = seconds;
          $("#infoLabel").empty();
          $("#successLabel").empty();
          $("#errorLabel").empty();
          $("#infoLabel").append('Please pay at Stripe Checkout');
          // ...и настраиваем и открываем Stripe
          handler.open({
            name: 'Voicy Bot',
            description: 'Purchasing ' + seconds + ' seconds',
            currency: 'USD',
            amount: purch,
            // alipay: true,
            // bitcoin: true
          });
        }
      });
    });
    

    index.js
    // Express router нужен для обработки входящих HTTP реквестов из jQuery
    const express = require('express');
    const router = express.Router();
    // db — это модуль, который я написал для работы с моей базой данных
    const db = require('../helpers/db');
    // Stripe — чтобы денежки с карточек клиентов снимать
    const stripe = require("stripe")("***");
    
    /** Проводим покупку */
    router.post('/buy', (req, res, next) => {
      // Получаем token от stripe, который позволит провести одну оплату
      const token = req.body.token;
      // Получаем id чата, в который добавляем секунды
      const chatId = parseInt(req.body.chatId);
      // Получаем, сколько секунд добавлять
      const amount = parseInt(req.body.amount);
    
      var charge = stripe.charges.create({
        amount: amount * 0.002 * 100, // Страйп считает в центах
        source: token,
        currency: "USD",
        description: "Buying seconds for Voicy"
      }, (err, charge) => {
        if (err) {
          res.send({ error: err.message }); // Перенаправляем ошибку назад в jQuery
        } else {
          db.findChat(chatId) // Ищем чат с подобным id
            .then((chat) => { // Блок, если все "ок"
              chat.seconds = parseInt(chat.seconds) + amount; // Добавляем секунды в чат
              return chat.save() // Сохраняем чат
                .then((newChat) => {
                  res.send({ success: true }); // Отправляем "ок" назад в jQuery
                });
            })
            .catch((err) => {
              res.send({ error: err.message }); // Перенаправляем ошибку назад в jQuery
            })
        }
      });
    });
    
    /* Получаем главную страницу */
    router.get('/:id', (req, res, next) => {
      const chatId = parseInt(req.params.id); // id чата, для которого рендерим страницу
      db.findChat(chatId) // Ищем чат по id
        .then((chat) => {
          // Не нашли? Бросаем ошибку 404!
          if (!chat) {
            const err = new Error();
            err.status = 404;
            err.message = 'No chat found';
            throw err;
          }
          // Нашли? Продолжаем работать с чатом!
          return chat;
        })
        .then((chat) => {
          // Рендерим страницу чата, передаем количество секунд в чате и id чата
          res.render('index', { 
            chatId: chat.id,
            seconds: chat.seconds,
          });
        })
        .catch(err => next(err)); // Возвращаем ошибку в обработчик ошибок (который просто рендерит страничку с ошибкой)
    });
    

    Главный вебсайт


    Любому хорошему проекту нужен добротный вебсайт — но мне что-то совсем не хочется тратить деньги на хостинг. Покупаем доменное имя и направляем его прямо на GitHub Pages, благо, с этим опыта достаточно с предыдущих проектов с открытым кодом. Берем стандартный шаблон, заполняем его нужными данными, слегка модифицируем index.html — готово.

    Nginx и SSL


    Но как мне будут доверять пользователи, если форма оплаты будет недоступна по https? Ничего страшного — плавали, знаем! Запускаем CertBot на сервере, получаем нужные SSL сертификаты. Пока что приложение доступно на pay.*domain*.com:3000 — негоже сюда направлять пользователей. Настраиваем Nginx, чтобы он перенаправлял все реквесты с http на https и вешаем прокси с 80 порта на 3000 — проверяем, заходим на сайт, работает.

    Если кому интересно, вот файл настроек nginx:

    Жми меня!
    # HTTP - переносим все реквесты в HTTPS:
    server {
            listen 80;
            listen [::]:80 default_server ipv6only=on;
            return 301 https://$host$request_uri;
    }
    
    # HTTPS - переносим все реквесты в наш Node.js сервер:
    server {
            listen 443;
            server_name your_domain_name;
    
           ssl on;
            # Используем сертификаты от Lets Encrypt:
            ssl_certificate /etc/letsencrypt/live/pay.voicybot.com/fullchain.pem;
            ssl_certificate_key /etc/letsencrypt/live/pay.voicybot.com/privkey.pem;
            ssl_session_timeout 5m;
            ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
            ssl_prefer_server_ciphers on;
            ssl_ciphers '***';
            # Переносим все реквесты в localhost:3001:
            location / {
                    proxy_set_header X-Real-IP $remote_addr;
                    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                    proxy_set_header X-NginX-Proxy true;
                    proxy_pass http://localhost:3000/;
                    proxy_ssl_session_reuse off;
                    proxy_set_header Host $http_host;
                    proxy_cache_bypass $http_upgrade;
                    proxy_redirect off;
            }
    }

    Заключение


    Вот так неожиданно и оборвался мой рассказ — так как все оказалось готово: один сервер принимает все голосовые сообщения и переводит их в текст, второй отвечает за безопасную оплату времени обработки войсов, а GitHub раздает головной сайт проекта. Главное, что я хотел описать в этой статье — это то, что процесс создания продукта хоть и трудоемкий, но может уместиться в 40-80 часов благодаря уже существующим инструментам в Интернете.

    Напомню, что этот маленький проект включает в себя:

    • Нейронную сеть от Google, конвертирующую голос в текст
    • Неплохой вебсайт, созданный при неимении каких-либо дизайн или фронтенд навыков
    • Умный инструмент оплаты, который, к слову, работает во всех браузерах и даже с AliPay да Биткоинами
    • Базу данных чатов и пользователей, по которой всегда можно глянуть статистику
    • Конвертацию аудио файлов между разными форматами
    • Сложный сервер с SSL, с двумя поднятыми комплексными приложениями
    • Отсутствие какого-либо человеческого фактора: все работает автоматически

    Если вы все еще думаете, что для создания собственного продукта у вас недостаточно навыков — оглянитесь вокруг, все уже сделано за вас. В отличие от того же 2000, сегодня создание продукта не требует глубокого знания алгоритмов и структур данных. Так чего же вы ждете?

    Благодарности


    Огромное спасибо, что дочитали до конца! Я с радостью отвечу на все комментарии к статье. Ссылку на сам бот не прилагаю (хоть он и полностью рабочий), так как это против правил Хабрахабра.

    Rock on.

    Share post

    Similar posts

    Comments 26

      +2
      Хотелось бы больше подробностей. Примеров кода по конвертации через ffmpeg, работу с Google Cloud Storage. Сейчас больше похоже на главу из очередной нон-фикшн книги с названием: «Бери от жизни все! Да, ты сможешь».

      И где можно бота в действии посмотреть-то?
        +1
        Добрый день! Постараюсь учесть ваш комментарий и дополнить статью примерами кода.

        К сожалению, боюсь, меня с Хабрахабра выпилят за прямую ссылку на бота.
          +1
          долго искал, вот ссылка https://telegram.me/voicybot
            0
            Добавил примеры кода и небольшие комментарии, спасибо!
            +2
            Без кода или ссылки на исходники не вижу смысла в упоминании JavaScrtipt и Node.js.
            То же самое могу сказать про слово «успех». Вы что то заработали на боте или пока только «успех»?
              +3
              Большое спасибо за комментарий! Бот написан на Node.js — в статье попутно указываю фреймворки, которые использовал, в частности, от Google. Обязательно учту ваш комментарий — добавляю примеры кода в статью прямо сейчас.

              Поискал слово «Успех» в своем тексте — нашел:

              Оказалось, Google принимает .flac — конвертируем, пробуем, все принимается, Google отвечает текстом, успех.

              Да, Google ответил текстом — вот он, мой «успех».
                0
                Добавил примеры кода. Спасибо за комментарий еще раз!
                –10
                image
                  +1
                  Это шедевр, только потому, что я упомянут в статье
                    +2
                    Делал что то подобное но только для яндекс speech kit лимиты запросов огорчают, если кому интересно отпишитесь в личку отправлю бота и расскажу подробности дабы за рекламу не сочли
                      +1
                      Аналогичные ограничения на родной iOS speechKit. В сравнении с Apple, Яндекс лучше распознает русские фамилии, но у меня не работал со служебным интернетом (прокси?), приходилось уходить на 3G или домой, что иногда приятно.
                        +2
                        Можете посмотреть wit.ai — полностью бесплатный сервис, там ограничение только на кол-во запросов в секунду.
                          0
                          Думаю, если выложите код на GitHub и приложите ссылочку — никто из администрации Хабрахабра против не будет :)
                          +1
                          Делал такого бота в качестве пробного, когда разбирался с Elixir'ом и Phoenix'ом
                          Кому интересно: https://github.com/sck-v/s2t_bot. Можно поднять своего на heroku

                          Сейчас всё в процессе рефакторинга, в пользу отказа от Phoenixframework в виду множества лишних функций
                            0
                            А откуда у вас эти ограничения?

                            Google API only accepts up to ~10-15 seconds of audio.
                            You can only make 50 requests per day to the Google speech API.

                            Боюсь сам на эти грабли наступить. Я тестил в боте 3х минутный войс, он перевелся. За последние 2 часа из-за Хабраэффекта было больше 50 реквестов в секунду — Google ни один не отклонил.
                              +1
                              Я пользовался этим описанием API:
                              https://github.com/gillesdemey/google-speech-v2#caveats. Оттуда и взял

                              Но за всё время пользования бот обрабатывал и длинные голосовые сообщения. Насчёт частоты не могу сказать, пользуюсь сам и не в таких объёмах
                                0
                                Спасибо за уточнение!
                            +1
                            Установил. Работает. Спасибо!
                              +1
                              Можно чуть подробней про
                              запускаем в командной строке «express payments»
                              ?
                                0
                                expressjs.com — удобный инструмент генерирования шаблонов Node.js приложений на основе, собственно говоря, Express :)

                                «express payments» генерирует проект на основе Express с названием «payments».
                                +2
                                я в своем боте с котиками тоже использую нейросети от гугла, для распознавания изображений. а вы не получали письмо про изменения в подписке Гугла, типа такого https://geektimes.ru/post/161717/?
                                  0
                                  Интересно! Я зарегистрировал аккаунт разработчика с Developers Console только четыре дня назад для этого бота, так что, думаю, я проспал вспышку этих email'ов :)

                                  Можете поделиться ссылочкой на своего бота, пожалуйста? Сколько примерно времени ушло на разработку?
                                    0
                                    а вот статья на гиктаймсе https://geektimes.ru/post/280044/
                                0
                                Пытался гуглом распознавать общение двух людей в телефоне — аццкое кургуду получается… :(((
                                  +1
                                  Мне, если честно, иногда и самому «распознать» речь из некоторых войсов друзей в Телеграме довольно сложно :)

                                Only users with full accounts can post comments. Log in, please.