Создание телеграм бота на Rust

В целях знакомства с Rust я решил сделать Телеграм Бота для чтения своей ленты в Inoreader.

Подготовка

Для начала нужно создать новое приложение в Inoreader и создать нового бота в Телеграм.

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

Создание модуля настроек

Для начала инициализируем новый проект с помощью cargo:

cargo init --bin inobot

Первым этапом будет создание модуля настройек для нашего бота. Настройки будут хранится в файле config.toml и содержать следующие параметры:

  • inoreader_appkey - ключ приложения inoreader;
  • inoreader_appid - идентификатор приложения;
  • inoreader_token - токен для работы с inoreader api;
  • inoreader_endpoint - url точки входа в inoreader api;
  • telegram_token - токен для работы с телеграм ботом;
  • telegram_endpoint - url для точки входа работы с телеграм ботом;
  • db_path - путь до БД хранилища чатов;
  • timeout - периодичность чтения новостей в сек;

Для парсинга toml файла будем использовать библиотеку toml-rs. Стурктура с настройками будет храниться в файле config.rs:

extern crate toml;

use std::fs::File;
use std::io::prelude::*;
use std::path::Path;

#[derive(Deserialize)]
pub struct Config {
    pub inoreader_appkey: String,
    pub inoreader_appid: String,
    pub inoreader_token: String,
    pub inoreader_endpoint: String,
    telegram_token: String,
    telegram_endpoint: String,
    pub db_path: String,
    pub timeout: u64,
}

impl Config {
    pub fn new(path: &str) -> Config {
        let file_name = Path::new(path);

        let mut file = File::open(&file_name).unwrap();

        let mut content = String::new();

        //читаем содержимое файла, если ошибка паникуем
        file.read_to_string(&mut content).unwrap();

        // производим маппинг настроек из файла в стуруктуру
        toml::from_str(&content.to_string()).unwrap()
    }

    pub fn get_telegram_bot_endpoint(&self) -> String {
        let mut main_ep = self.telegram_endpoint.to_owned();
        let bot_ep = format!("/bot{}", &self.telegram_token);

        main_ep.push_str(&bot_ep);
        main_ep
    }
}

Функция new получает на вход путь до конфиг файла и возвращает новый экземпляр конфига.

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

Также тут надо отметить, что в моем случае поля у структуры содержат тип String, который в Rust представляет собой что-то аналогичное вектору (Vec) и используется, когда планируется проводить какие-то действия со строковыми данными, так же в Rust есть еще тип str, который является неизменяемой последовательность UTF-8 символов переменной длинны.

Функция unwrap() также будет дальше часто встречаться, она служит для обработки ошибок. Данная функция при ошибке будет генерить panic() и завершать программу с выводом стектрейса.

Служебное слово pub означает, что стурктура, ее поле или метод будут доступны из вне, т. е. будут публичными.

Создание модуля для работы с Inoreader API

После того, как модуль работы с настройками был создан. Можно приступить к описанию объекта для работы с нужными функциями Inoreader API. В качетсве клиента для работы с http я выбрал модуль curl-rust, который является биндингом к libcurl.

Код клиента будет следующий:

extern crate serde_json;

use self::serde_json::Value;
use curl::easy::{Easy, List};
use std::str::FromStr;
use config::Config;

pub struct InoReaderClient<'a> {
    client: Easy,
    app_id: &'a String,
    app_key: &'a String,
    app_token: &'a String,
    endpoint: &'a String
}

impl<'a> InoReaderClient<'a> {
    pub fn new(conf: &Config) -> InoReaderClient {
        let mut client = InoReaderClient {
            client: Easy::new(),
            app_id: &conf.inoreader_appid,
            app_token: &conf.inoreader_token,
            app_key: &conf.inoreader_appkey,
            endpoint: &conf.inoreader_endpoint
        };
        client.set_headers();
        client
    }
	
	fn set_headers(&mut self) {
        let mut header_list = List::new();
		
		// конкатенация строк
        let mut app_id: String = "AppId: ".to_owned();
        app_id.push_str(&self.app_id);
        header_list.append(&app_id).unwrap();

        let mut app_key: String = "AppKey: ".to_owned();
        app_key.push_str(&self.app_key);
        header_list.append(&app_key).unwrap();

        let mut app_token: String = "Authorization: GoogleLogin auth=".to_owned();
        app_token.push_str(&self.app_token);
        header_list.append(&app_token).unwrap();

        &self.client.http_headers(header_list).unwrap();
    }
}

В этом коде объявляется стурктура InoReaderClient, у который есть 2 метода:

  1. ```new`` - который создает новый объект, используя конфиг, указанный в параметре;
  2. set_headers - который устанавливает нужные http заголовки клиенту.

Также надо отметить что, так как, стурктура содержит ссылочные поля, то у нее должно быть задано “время жизни” (в нашем случае <'a>), это сделано для того, чтобы не получилось ситуации, что поле будет ссылаться на очищенную область памяти.

Когда основа клиента создана, можно добавлять к ней полезную функциональность.

Первое что хотелось бы добавить - это функция отправки запроса к api серверу. Данная функция будет выглядеть так:

impl<'a> InoReaderClient<'a> {
	
	...

	fn get(&mut self, endpoint: &str) -> Result<String, u32> {
		let mut ep = self.endpoint.to_owned();
		let mut dst = Vec::new();

	    ep.push_str(endpoint);
	    &self.client.url(&ep).unwrap();
	
	   // вынесено в для освобождения заимствования после получения ответа
	   {
		   let mut transfer = &mut self.client.transfer();
		   transfer.write_function(|data| {
		    	dst.extend_from_slice(data);
			    Ok(data.len())
           }).unwrap();
           transfer.perform().unwrap();
        }

	    let response_code = self.client.response_code().unwrap();
        match response_code {
            200 => Ok(String::from_utf8(dst).unwrap()),
            _ => Err(response_code),
        }
	}

	...
}

В этой функции на вход подается url эндпоинта из API, затем он контатенируется с главной точкой входа и оптравляется запрос по этому адресу. В результате функция вернет перечеслине Result, которая отвечает в Rust за обработку результата. При выполнении запроса первым ее элементом будет строка ответа, а вторым - код ошибки, если запрос отработает не корректно. Перечесление генерируется функцией match (это что-то похожее на case в С).

Как видно часть функции, описанной выше, вынесена в отдельную область видимости, чтобы не терять доступ к данным клиента после чтения ответа. Так как после присвания мутабельной переменной предыдущая становится не доступна.

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

impl<'a> InoReaderClient<'a> {
	
	...

	    pub fn get_unread_count(&mut self) -> u64 {
			let response = self.get("/unread-count").unwrap();
			let v: Value = serde_json::from_str(&response).unwrap();

	        v["unreadcounts"][0]["count"].as_u64().unwrap()
		}

	...
}

В данной функции происходит обращение к эндпоитну /unread-count, который используется для получения информации о не прочитанных новостях. Далее мы парсим полученную строку ответа в json, с помощью serde-json. И возвращаем число непрочитанных новостей из него.

Теперь напишем обработчик получения непрочитанных новостей от сервера.

Для хранения новостей будет использоваться отдельная структура:

#[derive(Clone)]
pub struct News {
    title: String,
    url: String
}

impl News {
    pub fn to_markdown(&self) -> String {
        format!(
            "[{}]({})",
            &self.title.replace("[", "(").replace("]", ")"),
            &self.url
        )
    }
}

Данная структура содержит заголовок новости и ссылку на неё. Кроме того у структуры задано, что она может клонироваваться (Clone), т.е. при необходимости можно полностью дублировать структуру в памяти(зачем будет показано ниже). Кроме трейта Clone, есть ещё трейт Copy, который тоже копирует структуру, но делает это не явно в отличии от Clone, и хотя он менее “дорогой”, не все типы его поддерживают. Трейт - это языковая функция, которая сообщает компилятору Rust о функциональности, которую должен предоставить тип.

Также описана функция для перевода новости в формат Markdown (назначение этого будет видно дальше).

Теперь все готово для создания метода получения списка новостей:

impl<'a> InoReaderClient<'a> {
	
	...

    pub fn get_last_news(&mut self, count: u64) -> Vec<News> {
        let ep = format!("/stream/contents/?n={}", count);

        let response = self.get(&ep).unwrap();
        let r: Value = serde_json::from_str(&response).unwrap();

        let items = r["items"].as_array().unwrap();

        let mut news = Vec::new();
        for i in items {
            news.push(
                News {
                    title: String::from_str(i["title"].as_str().unwrap()).unwrap(),
                    url: String::from_str(i["canonical"][0]["href"].as_str().unwrap()).unwrap(),
                }
            );
        };
        news
    }
	
	...
}

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

Описанных возможностей должно хватить, для нашей задачи и поэтому можно перейти к написанию объекта для работы с Telegram.

Создание модуля для работы с Telegram API

Для начала создадим базовую структуру для работы с API. Ее код будет таким:

extern crate serde_json;

use self::serde_json::Value;
use std::str::FromStr;
use curl::easy::Easy;
use config::Config;
use inoreader::News;

pub struct TelegramBotClient {
    client: Easy,
    endpoint: String
}

impl TelegramBotClient {
    pub fn new(conf: &Config) -> TelegramBotClient {
        TelegramBotClient {
            client: Easy::new(),
            endpoint: conf.get_telegram_bot_endpoint()
        }
    }
}

Теперь создадим функцию отвечающую за отправку запросов серверу:

impl TelegramBotClient {
	...
	
    fn get(&mut self, endpoint: &str) -> Result<Value, String> {
        let mut ep = self.endpoint.to_owned();
        let mut response = Vec::new();
        ep.push_str(endpoint);
        &self.client.url(&ep).unwrap();

        // вынесено в для освобождения заимствования после получения ответа
        {
            let mut transfer = &mut self.client.transfer();
            transfer.write_function(|data| {
                response.extend_from_slice(data);
                Ok(data.len())
            }).unwrap();
            transfer.perform().unwrap();
        }

        let response_code = self.client.response_code().unwrap();
        match response_code {
            200 => Ok(self.parse_response(response)),
            _ => Err(self.get_error_msg(response)),
        }
    }

    fn parse_response(&self, response: Vec<u8>) -> Value {
        let resp = String::from_utf8(response).unwrap();
        serde_json::from_str(&resp).unwrap_or_default()
    }

    fn get_error_msg(&self, response: Vec<u8>) -> String {
        let resp = &self.parse_response(response);
        String::from_str(resp["description"].as_str().unwrap_or_default()).unwrap_or_default()
    }
}

Данная функция немного отличается от той, что описана в реализации клиента для Inoreader API. Это связано с тем, что телеграм в ответе на неверный запрос присылает еще и описание ошибки, которое в данном коде будет возвращаться, в случае неудачного запроса. В случае удачного запроса вернется структура, которая содержит json ответ.

Теперь когда у нас есть функция для работы с сервером, мы можем получить от него все id чатов активных на данный момент:

impl TelegramBotClient {
	...
	
    pub fn get_chart_ids(&mut self) -> Vec<i64> {
        // getUpdates получает все сообщения которые были отправлены боту на текущий момент
        let response = self.get("/getUpdates").unwrap();

        // из этих сообщений получем id чатов для рассылки
        let messages = response["result"].as_array().unwrap();

        let mut chart_ids: Vec<i64> = Vec::new();
        for m in messages {
            chart_ids.push(
                m["message"]["chat"]["id"].as_i64().unwrap()
            )
        }
        chart_ids
    }

	...
}	

Данная функция возвращает вектор из id чатов, в которые в будущем будет осуществляться рассылка.

Для того чтобы чат попал в рассылку нужно отправить боту любое сообщение, тогда при запросе getUpdates бот вернет все сообщения из открытых чатов за сутки. Также обновления из Telegram можно получать через Webhook, но для этогу нужен https сертификат.

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

impl TelegramBotClient {
	...
	
    pub fn send_message(&mut self, news: News, chat_id: i64) {
        let msg_url = format!(
            "/sendMessage?chat_id={}&text={}&parse_mode=Markdown",
            chat_id,
            news.to_markdown(),
        );
        match self.get(&msg_url) {
            Ok(_) => println!("News success send"),
            Err(e) => println!("Can't send news: {:?}", e),
        };
    }
	
	...
}	

На вход она получает инденификатор чата и новость, которую надо в него отправить. Новость отправляется в формате markdown. Также тут для обработки ошибок используется match, вместо unwrap, так как при ошибке отправки какой-либо новости программа не должна падать с ошибкой.

Создание модуля для хранилища мета-данных

Так как обновления с помощью Telegram API мы можем получить только за сутки, то необходимо где-то хранить ранее полученные id чатов. Для этого реализуем отдельную струтктуру хранилица, которая будет складывать id чатов в БД SQLite.

Для работы с БД я выбрал библиотеку rusqlite. Код хранилища будет следующий:

use rusqlite::Connection;
use config::Config;

pub struct Store {
    conn: Connection
}

impl Store {
    pub fn connect(conf: &Config) -> Store {
        let conn = Connection::open(&conf.db_path).unwrap();
        conn.execute("CREATE TABLE IF NOT EXISTS chats (id INTEGER);", &[]).unwrap();
        Store {
            conn: conn,
        }
    }

    pub fn add_chart(&self, chat_id: i64) {
        &self.conn.execute(
            "INSERT INTO chats (id) VALUES (?1)",
            &[&chat_id]
        ).unwrap();
    }

    pub fn get_chart_ids(&self) -> Vec<i64> {
        let mut stmt = self.conn.prepare("SELECT id FROM chats").unwrap();
        let chat_iter = stmt.query_map(&[], |row| {row.get(0)}).unwrap();

        let mut charts: Vec<i64> = Vec::new();
        for c in chat_iter {
          charts.push(c.unwrap());
        };

        charts
    }
}

Как видно из кода у хранилища есть 3 функции:

  1. connect - устаноавливает соединение с БД и при инициализации создает таблицу для хранения id чатов;
  2. add_chart - добавляет в хранилище id чата из пареметра;
  3. get_chart_ids - получет список доступных чатов;

Создание главного кода приложения

Когда все вспомогательные модули я описал пришло время объединить их в одно приложение. Его код будет выглядеть следующим образом:

#[macro_use]
extern crate serde_derive;
extern crate curl;
extern crate rusqlite;

pub mod config;
mod inoreader;
mod telegram;
mod store;

use std::thread;
use std::process;
use std::time::Duration;
use std::thread::sleep;

fn main() {
    // получаем список аргументов командной строки
    let cmd_args: Vec<_> = std::env::args().collect();

    if cmd_args.len() < 2 {
        println!("Illegal parameter use: inobot <config_path>");
        process::exit(1);
    }
    let cfg = config::Config::new(&cmd_args[1]);
    let store = store::Store::connect(&cfg);

    let mut inoreader_client = inoreader::InoReaderClient::new(&cfg);
    let mut telegram_bot = telegram::TelegramBotClient::new(&cfg);

    loop {
        let new_chats = telegram_bot.get_chart_ids();
        let mut saved_charts = store.get_chart_ids();
        for c in new_chats {
            if !saved_charts.contains(&c) {
                store.add_chart(c);
                saved_charts.push(c);
            }
        }

        let unread_count = inoreader_client.get_unread_count();
        let news: Vec<inoreader::News> = inoreader_client.get_last_news(unread_count);

        for c in saved_charts {
            let sending_news = news.clone();
            let mut bot = telegram::TelegramBotClient::new(&cfg);
            thread::spawn(move || {
                for n in sending_news {
                    bot.send_message(n, c);
                }
            });
        }

        // ждем timeout в секундах для следующего обращения
        sleep(Duration::from_secs(cfg.timeout));
    }
}

Разбереем что происходит в этом коде.

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

Далее создаем экземпляры для работы с Inoreader API и Telegram API и запускаем основной цикл для работы бота. В цикле мы считываем новые id чатов, в которые будут пересылаться сообщения, и, если их не было, добавляем их к списку существующих из хранилища. Затем получаем кол-во непрочитанных сообщений и считываем их из inoreader.

Особое внимание стоит уделить циклу:

for c in saved_charts {
	let sending_news = news.clone();
	let mut bot = telegram::TelegramBotClient::new(&cfg);
	let _ = thread::spawn(move || {
	        for n in sending_news {
	        bot.send_message(n, c);
		}
	}).join();
}

В нем осуществляется отправка новых сообщений во все доступные чаты, причем для каждого чата создается отдельный поток. Для безопасной работы с потоками создается копии списка новостей для каждого потока, так как структура содержит поля тип String, которому метод Copy задать нельзя. Rust использует данный метод для безопасной работы с данными в разных потоках. Также для бота создаем новый инстанс, так как струтура TelegramBotClient не может быть склонирована или скопирована, по причине того, что содержит curl::easy::Easy, которая не поддерживает ни один из этих методов.

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

Заключение

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

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

Итоговый код бота лежит на GitHub.

Если вдург вы найдете неточность или ошибку в этой статье, сообщите мне, пожалуйста.

Ссылки:

  1. Telegram Bot API
  2. Inoreader API
  3. InoBot
  4. Rust
 
comments powered by Disqus