Доброй ночи! Сегодня хотелось бы кратко рассказать о том, как написать Telegram-бота на Rust, который будет запускать код на Rust. У статьи нет цели произвести полное погружение в API telegram_bot, Serde, Telegram или в нюансы разработки на Rust. Она скорее носит ознакомительный характер. Числа Пеано с помощью системы типов складывать не будем.
Превью к статье



Создание бота в Telegram


Временная ссылка на бота, чтобы можно было посмотреть результат


Для начала создадим бота и получим HTTP API токен.


Заходим к этому парню и пишем следующее:


Инициируем создание нового бота: /newbot.
Ответ крёстного отца:


Alright, a new bot. How are we going to call it? Please choose a name for your bot.

В ответе пишем имя бота, которого хотим создать: rust.
Ответ крёстного отца:


Good. Now let's choose a username for your bot. It must end in bot. Like this, for example: TetrisBot or tetris_bot.

Следуя указаниям, вводим ещё одно имя: rustlanguage_bot.
Ответ крёстного отца:


Done! Congratulations on your new bot. You will find it at t.me/rustlanguage_bot. You can now add a description, about section and profile picture for your bot, see /help for a list of commands. By the way, when you've finished creating your cool bot, ping our Bot Support if you want a better username for it. Just make sure the bot is fully operational before you do this. Use this token to access the HTTP. API: %TOKEN% For a description of the Bot API, see this page: https://core.telegram.org/bots/api

Отлично. Бот создан. %TOKEN% — это, собственно, и есть токен.


Rust Playground


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


Есть такой сервис Rust Playground, который позволяет запускать простейший Rust код онлайн. Им и воспользуемся. Располагается он по данному адресу: https://play.rust-lang.org/


Перейдя по ссылке, введём простую hello-world программу:


fn main() {
    println!("Hello world!");
}

Откроем вкладку Network из DevTools, чтобы посмотреть, что и в каком формате он шлёт для получения результатов компиляции:


Rust Playground Request


Вроде бы всё прозрачно и понятно. Попробуем воспроизвести из консоли:


[loomaclin@localhost ~]$ curl -X POST -d '{"code":"fn main() {\n    println!(\"Hello world!\");\n}","version":"stable","optimize":"0","test":false,"separate_output":true,"color":true,"backtrace":"0"}' https://play.rust-lang.org/evaluate.json
{"program":"Hello world!\n","rustc":"rustc 1.16.0 (30cf806ef 2017-03-10)\n"}

Отлично, поехали дальше.


Пишем бота


Создаём проект:


cargo new rust_telegram_bot --bin

Добавим следующие зависимости в Cargo.toml:


[dependencies]
telegram-bot = { git = "https://github.com/White-Oak/telegram-bot.git" }
hyper = "0.10.8"
hyper-rustls = "0.3.2"
serde_json = "0.9.10"
serde = "0.9.14"
serde_derive = "0.9.14"

Кратко опишу, зачем они нужны:


  • Serde предназначена для сериализации/десериализации данных в различных форматах. В данном случае нам необходима работа с JSON (serde_json) и щепотка кодогенерации (serde_derive);


  • Hyper для работы с сетью будем использовать HTTP-клиент, который она предоставляет для взаимодействия с Rust Playground. Так как взаимодействие производится по протоколу HTTPS, ещё необходима батарейка в виде hyper-rustls;


  • ну и самое главное, для взаимодействия с Telegram API будем использовать готовую библиотеку telegram-bot, но не конкретно её, а форк товарища @white_oak, который подогнал её для работы с актуальной версией Hyper.

В src/main.rs подключим все необходимые библиотеки и модули:


extern crate telegram_bot;
extern crate hyper;
extern crate hyper_rustls;
extern crate serde_json;
extern crate serde;
#[macro_use]
extern crate serde_derive;

use serde_json::Value;
use telegram_bot::{Api, MessageType, ListeningMethod, ListeningAction};
use std::io::Read;
use hyper::client::Client;
use hyper::net::HttpsConnector;
use hyper_rustls::TlsClient;

Примечание: #[macro_use] используется для включения в область видимости текущей программы макросов из библиотеки, к которой был применён данный атрибут.


В данной строке импортируем модули из корня библиотеки для определения типа сообщения, метода "прослушки", структуры представляющей API Telegram:


use telegram_bot::{Api, MessageType, ListeningMethod, ListeningAction};

Опишем с помощью enum возможные виды ответов от сервера, а их в данном случае 2, когда программа была скомпилирована успешно, и когда произошла ошибка компиляции:


#[derive(Serialize, Deserialize, Debug)]
#[serde(untagged)]
pub enum ResponseType {
    ProgramCompiled { program: String, rustc: String },
    ProgramCompileError { rustc: String }
}

Заметили атрибут #[serde(untagged)], который был применён к перечислению? Он говорит о том, что при (де)сериализации для вариантов перечисления не будет искаться какой-либо тэг, явно указывающий на то, каким из вариантов он является. Так как же Serde определит, какой из вариантов ответа на запрос от сервера мы получили? На самом деле, она будет пытаться десериализовывать в каждый из вариантов, пока не дойдёт до первого успешного результата. Более подробно об этом можно почитать в официальной документации: https://serde.rs/enum-representations.html.


Определим структуру для нашего запроса в Rust Playground:


#[derive(Serialize)]
pub struct PlaygroundRequest {
    code: String,
    version: String,
    optimize: String,
    test: bool,
    separate_output: bool,
    color: bool,
    backtrace: String
}

Из пользовательского ввода в эту структуру пойдёт только поле code. Остальное захардкодим, ибо всегда так делаем :) (нет)


В главной функции программы main создадим инстанс Telegram API и заставим его печатать всё, что пришло боту в сообщении:


fn main() {
    let api = Api::from_env("TOKEN").unwrap();
    println!("getMe: {:?}", api.get_me());
    let mut listener = api.listener(ListeningMethod::LongPoll(None));

    let res = listener.listen(|u| if let Some(m) = u.message {
                                  let name = m.from.first_name;
                                  match m.msg {
                                      MessageType::Text(t) => {
                                            println!("<{}> {}", name, t);
                                        }
                                      _ => {}
                                  }
                              });
}

Чтобы проверить работоспособность данного кода, запустите программу, не забыв передать в качестве переменной окружения реальный токен, полученный ранее:


TOKEN=%TOKEN% cargo run

Немного разберём, что мы написали выше.


 let api = Api::from_env("TOKEN").unwrap();
    println!("getMe: {:?}", api.get_me());

Здесь мы создаём инстанс структуры Api, импортированной из telegram_bot, далее создаём слушатель бота в режиме long-polling:


let mut listener = api.listener(ListeningMethod::LongPoll(None));

Под конец создаём цикл обработки сообщений при помощи функции listen и сопоставления по шаблону типа сообщения:


    let res = listener.listen(|u| if let Some(m) = u.message {
                                  let name = m.from.first_name;
                                  match m.msg {
                                      MessageType::Text(t) => {
                                            println!("<{}> {}", name, t);
                                        }
                                      _ => {}
                                  }
                              });

Условимся, что код мы будем передавать только в текстовом виде. Файлы и прочее исключим. Для этого, как вы могли заметить, все остальные варианты перечисления MessageType просто игнорируются.


Обрабатываем команду /rust, отправляя запрос на Rust Playground, и считываем ответ:


    if t.starts_with("/rust ") {
        let program = t.split("/rust ").collect();
        let mut result = String::new();
        let tls = hyper_rustls::TlsClient::new();
        let connector = HttpsConnector::new(tls);
        let client = Client::with_connector(connector);
        let playground_request = serde_json::to_string(&PlaygroundRequest {
                                                            code: program,
                                                            version: String::from("stable"),
                                                            optimize: String::from("0"),
                                                            test: false,
                                                            separate_output: true,
                                                            color: false,
                                                            backtrace: String::from("0"),
                                                        })
                                                 .unwrap();
        let mut response = client
            .post("https://play.rust-lang.org/evaluate.json")
            .body(&playground_request)
            .send()
            .unwrap();
        response.read_to_string(&mut result);
        println!("Result : {:?}", result);
    }

Мы обрабатываем запрос только лишь в случае, если сообщение начинается с определённой команды (/rust):


if t.starts_with("/rust ") {

А так же вытаскиваем код программы, которую необходимо скомпилировать:


 let program = t.split("/rust ").collect();

Функция serde_json::to_string(&PlaygroundReques { ... }) сериализует нашу структуру запроса в строку. Остальная часть кода относится к инициализации HTTPS клиента, отправке и чтению запроса, об этом подробней можно прочесть здесь: https://hyper.rs/hyper/v0.10.7/hyper/index.html.


Обрабатываем пришедший ответ:


  let result : ResponseType = serde_json::from_str(&result)
                            .unwrap_or(ResponseType::ProgramCompileError {
    rustc: String::from("Ответ на запрос не удалось десериализовать")  });
    let mut result = match result {
        ResponseType::ProgramCompiled { program, .. } => {
            format!("Программа скомпилирована успешно: {}",
                    program)
        }
        ResponseType::ProgramCompileError { rustc, .. } => {
            format!("Ошибка компиляции программы: {}",
                    rustc)
        }
    };

Функция serde::from_str десериализует пришедший ответ в один из вариантов нашего enum. В случае, если ответ не удалось десериализовать, для упрощения мы заворачиваем это в вариант ошибки компиляции с соответствующим текстом. Далее мы формируем наше результирующее сообщение, которое будет отослано пользователю, основываясь на том, какой из вариантов enum был представлен. Возможно, вы в первый раз видите при сопоставлении по шаблону конструкцию вида { program, .. }, объясню — это игнорирование при деструктуризации полей структуры, которые нам не нужны в ходе обработки этого варианта.


Отправка результатов компиляции в чат:


    if result.len() > 500 {
        result.truncate(500);
    }
    try!(api.send_message(m.chat.id(), result, None, None, Some(m.message_id), None));

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


Проверяем работоспособность


Чат с rust телеграм ботом


Вывод в консоль:


     Finished dev [unoptimized + debuginfo] target(s) in 2.38 secs
     Running `target/debug/rust_telegram_bot`
getMe: Ok(User { id: 334562900, first_name: "rust", last_name: None, username: Some("rustlanguage_bot") })
<Arsen> /rust abc
Result : "{\"rustc\":\"rustc 1.16.0 (30cf806ef 2017-03-10)\\nerror: expected one of `!` or `::`, found `<eof>`\\n --> <anon>:1:1\\n  |\\n1 | abc\\n  | ^^^\\n\\nerror: aborting due to previous error\\n\\n\"}"
<Arsen> /rust fn main() { println!("Hello habrahabr!"); }
Result : "{\"program\":\"Hello habrahabr!\\n\",\"rustc\":\"rustc 1.16.0 (30cf806ef 2017-03-10)\\n\"
}"

Заключение


Думаю, на этом всё. Спасибо WhiteOak за рабочий форк telegram_bot.
Кстати, у него есть проект биндингов к QML из Rust: https://github.com/White-Oak/qml-rust. Возможно, кому-то это будет интересным.


Любая конструктивная критика приветствуется.
Репозиторий с полным кодом данного бота располагается здесь.


Чуть не забыл оставить ссылку на чат русскоговорящего сообщества Rust, где вам всегда помогут совладать с языком: https://gitter.im/ruRust/general

Поделиться с друзьями
-->

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


  1. Gexon
    19.04.2017 01:43
    +3

    Отличная статья! Огромное спасибо!


    1. LooMaclin
      19.04.2017 06:04
      +2

      Спасибо. Не за что. При написании своего бота — обращайтесь, если возникнут какие-либо вопросы.


  1. kez
    19.04.2017 03:49
    +3

    Все бы хорошо, но


    единственная проблема: на странице репозитория написано, что библиотека (временно, уже достаточно давно) не развивается, и ожидается существенное ее переписывание.


    Оригинальное сообщение

    This library will undergo a major rewrite in the next few months. Currently the development is stalled and many new API parts are not supported right now! If you need to write a production ready application, either wait or use another (non-Rust) library. We're sorry :(


    1. LooMaclin
      19.04.2017 06:13
      +5

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


  1. tgz
    19.04.2017 08:51

    Ну вот зачем на таком прекрасном языке писать такие бесполезные вещи? И так уже каждая вторая статья «пишем бота на $lang».


    1. LooMaclin
      19.04.2017 08:55
      +1

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


  1. torkve
    19.04.2017 12:43
    +1

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


    Никто ж не будет писать весь код в одну строку и без форматирования :)


    1. LooMaclin
      19.04.2017 13:19

      Дело в проверке starts_with("/rust "), не обрабатываются случаи когда там /rust\n, т.е всегда ожидает пробел после ключевого слова.


  1. it3a
    19.04.2017 12:55
    +4

    Стоит добавить автоматическую обертку в fn main(), если сам не обернул.


    1. LooMaclin
      19.04.2017 13:22
      +1

      Да добавить можно. Уточню, что бот, который указан в данной статье — временный. Как пример для статьи, а не для постоянного использования.