Привет, меня зовут Илья, я сейчас сдаю вступительные экзамены в магистратуру. Столкнулся при поступлении с проблемой, что результаты экзаменов в рейтинговом списке появляются не сразу, а постоянно его открывать и находить себя на странице - после раза двадцатого надоело. После исследования devtools я захотел написать приложение для отслеживания изменений рейтинга, а уведомления отправлять в телеграм. А Rust был выбран по простой причине - он мне понравился, ну и есть удобные штуки всякие.
Не судите строго, мой первый опыт написания статьи (и бота). Также она не претендует на звание полноценного туториала по разработке телеграм ботов на Rust, но я старался. И тем более это не туториал по самому языку.
Реализация
Для запросов использовал reqwest с включенной фичей json
для десериализации, для поддержки async/await
- tokio, для работы с SQLite - rusqlite.
Пока что есть возможность смотреть рейтинг только для магистратуры ИТМО
Схема работы
Получаем список программ и сохраняем в базе, далее запускаем цикл, на каждом шаге запрашиваем и обрабатываем обновления с телеграма, и примерно каждые 10 минут обновляем рейтинг с сайта.
Получение рейтинга
Начал я с получения данных о рейтинге с abitlk.itmo.ru
. Здесь получаем данные, функция find_score
ищет в них нужную запись, в случае если не находит, возвращает ошибку, которая будет обработана, и будет отправлено сообщение, что ничего не найдено
get_rating_competition
pub async fn get_rating_competition(
degree: &str,
program_id: &str,
case_number: &str,
) -> Result<Option<Competition>, Box<dyn std::error::Error>> {
let rating_response: RatingResponse = reqwest::get(format!(
"{API_PREFIX}/{API_KEY}/rating/{degree}/budget?program_id={program_id}"
))
.await?
.json()
.await?;
match find_score(rating_response, case_number) {
None => Err(Box::from("no matching competition")),
competition => Ok(competition),
}
}
Поиск записи в данных. Если в поле ok
вернулось false
, сразу возвращаем None
. Иначе достаем массив всех записей из response.result.general_competition
, и находим в нем позицию по номеру дела из личного кабинета (у меня вида Мх-хххх-2022
)
find_score
fn find_score(response: RatingResponse, case_number: &str) -> Option<Competition> {
if !response.ok {
return None;
}
response
.result
.general_competition
.iter()
.find(|c| -> bool {
if let Some(c) = &c.case_number {
c == case_number
} else {
false
}
})
.cloned()
}
Структура RatingResponse
выглядит так. Тут я задал только нужные поля, остальные, которые придут в ответе, будут проигнорированы при парсинге.
Модели ответа
pub struct Response<T> {
pub ok: bool,
pub message: String,
pub result: T,
}
pub type RatingResponse = Response<RatingResult>;
pub struct RatingResult {
pub general_competition: Vec<Competition>,
}
pub struct Competition {
pub position: i32,
pub priority: i32,
pub total_scores: f64,
pub case_number: Option<String>,
pub exam_scores: Option<f64>,
}
Также каждая структура наследует serde::Deserialize
для генерации нужных методов, которые будут потом преобразовывать сырые данные (в данном случае json) в структуру:
#[derive(Deserialize)]
pub struct Response<T> {}
Получение команд от пользователей
Для получения сообщений используется эндпойнт https://api.telegram.org/botTOKEN/getUpdates?offset=x
для получения обновлений, где есть и новые сообщения. offset
необходим для пометки обновлений как прочитанных, чтобы они больше не возвращались. Функция для их получения:
get_updates
async fn get_updates(offset: i32) -> Result<GetUpdatesResponse, Box<dyn std::error::Error>> {
let params = [("offset", &offset.to_string())];
let url =
reqwest::Url::parse_with_params(&format!("{TG_API_PREFIX}{TOKEN}/getUpdates"), ¶ms)?;
let response = reqwest::get(url).await?;
if !response.status().is_success() {
let error: ErrorResponse = response.json().await?;
let text = format!(
"cannot get updates\nerror: `{}`",
error.description.unwrap_or_default()
);
send_message(&text, LOGS_CHAT_ID).await?;
return Err(Box::from("cannot get updates"));
}
Ok(response.json().await?)
}
Полученные данные обрабатываем. Здесь во-первых сохраняется максимальный update_id
для последующего использования в качестве offset
, далее в MessageRequest::from(text)
парсится текст сообщения, если это известная команда, она далее обрабатывается, если нет, отправляется сообщение, что это не команда.
handle_updates
pub async fn handle_updates(db: &DB, offset: i32) -> Result<i32, Box<dyn std::error::Error>> {
let data = get_updates(offset).await?;
let mut max_update_id = 0;
for update in data.result {
if update.update_id > max_update_id {
max_update_id = update.update_id;
}
if let Some(message) = update.message {
if let Some(text) = message.text {
let chat_id = message.from.id.to_string();
match MessageRequest::from(text) {
Some(request) => handle_message_request(db, request, &chat_id).await?,
None => send_message(messages::unknown_message, &chat_id).await?,
}
}
}
}
Ok(max_update_id + 1)
}
Парсится так (функция from
). Текст разбивается по пробелам, далее в случае односложных команд просто возвращается значение перечисления, а в случае /watch
из значений массива создается объект для их передачи. При неправильной команде возвращается её имя, далее оно будет использовано для отправки синтаксиса этой команды.
MessageRequest::from
impl MessageRequest {
pub fn from(text: String) -> Option<Self> {
let text: Vec<String> = text.split(' ').map(|w| w.to_string()).collect();
if text.is_empty() {
return None;
}
let command = text[0].as_str();
match command {
"/watch" | "/unwatch" => {
let incorrect_command = Some(Self::IncorrectCommand(command.to_string()));
if text.len() < 5 {
if text.len() == 2 && text[1] == "all" {
return Some(Self::UnwatchAll);
}
return incorrect_command;
}
// waiting for let-chain
if let Some(watch) = Watch::new("itmo", &text[2], &text[3], &text[4]) {
if watch.degree == Degree::Master {
return match command {
"/watch" => Some(Self::Watch(watch)),
"/unwatch" => Some(Self::Unwatch(watch)),
_ => incorrect_command,
};
}
}
incorrect_command
}
"/about" => Some(Self::About),
"/help" => Some(Self::Help),
"/start" => Some(Self::Start),
_ => None,
}
}
}
Структура MessageRequest
и вспомогательные Degree
и Watch
:
Модели запроса пользователя
pub enum Degree {
Bachelor,
Master,
Postgraduate,
}
pub struct Watch {
pub uni: String,
pub degree: Degree,
pub program_id: String,
pub case_number: String,
}
pub enum MessageRequest {
About,
Help,
Start,
Unwatch(Watch),
UnwatchAll,
Watch(Watch),
IncorrectCommand(String),
}
MessageRequest
обрабатывается так. Для простых команд отправляем соответствующие сообщения, команды /watch
и /unwatch
обрабатываем (сама функция обработки чуть ниже).
handle_message_request
async fn handle_message_request(
db: &DB,
request: MessageRequest,
chat_id: &str,
) -> Result<(), CrateError> {
match request {
MessageRequest::Watch(args) => {
let result = handle_competition(db, chat_id, &args.degree.to_string(), &args.case_number, &args.program_id, true).await;
if let Err(_) = result {
send_message(messages::rating_not_found, chat_id).await?;
}
}
MessageRequest::Unwatch(args) => {
db.delete_competition(
&args.case_number,
chat_id,
&args.program_id,
&args.degree.to_string(),
)?;
send_message(messages::done, chat_id).await?;
}
MessageRequest::UnwatchAll => {
db.delete_competition_by_user(chat_id)?;
send_message(messages::done, chat_id).await?;
}
MessageRequest::IncorrectCommand(command) => {
send_incorrect_command_message(&command, chat_id).await?
}
MessageRequest::Help => send_message(messages::help, chat_id).await?,
MessageRequest::Start => send_message(messages::start, chat_id).await?,
MessageRequest::About => send_message(messages::about, chat_id).await?,
};
Ok(())
}
Код обработки довольно скучный, но довольно большой. Сначала получается позиция, достается уже ранее запрошенная позиция из базы, если они не совпадают, то отсылается сообщение пользователю, и значение в базе обновляется. Если не равны, то сообщение отсылается только в случае, если эта функция была вызвана после обработки команды от пользователя, а не на этапе регулярной проверки обновлений. За это отвечает параметр is_user_request
:
handle_competition
pub async fn handle_competition(
db: &DB,
chat_id: &str,
degree: &str,
case_number: &str,
program_id: &str,
is_user_request: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let competition = get_rating_competition(db, degree, program_id, case_number).await?;
match db.select_competition(chat_id, case_number, degree, program_id) {
Ok(old_competition) => {
if let Some(competition) = competition {
let program = db.select_program("itmo", program_id)?;
let program_name = if let Some(program) = program {
program.title_ru
} else {
"Названия нет".to_string()
};
let mut should_send_message = false;
// update if competition is old (competition != old_competition)
// insert if is new (when old == None, on first user request)
if let Some(old_competition) = old_competition {
if competition != old_competition {
db.update_competition(&competition, chat_id, program_id, degree)?;
should_send_message = true;
}
} else {
db.insert_competition(&competition, chat_id, program_id, degree)?;
}
// send if it's user request or record in db was updated
if is_user_request || should_send_message {
send_competition_message(&competition, chat_id, &program_name).await?;
}
}
}
Err(e) => {
eprintln!("cannot select competition: {e}")
}
};
Ok(())
}
Отправка сообщения выглядит так. Экранируем некоторые символы, отправляем запрос, проверяем результат.
send_message
pub async fn send_message(text: &str, chat_id: &str) -> Result<(), Box<dyn std::error::Error>> {
let text = &text.replace('-', "\\-").replace('.', "\\.");
let params = [
("chat_id", chat_id),
("text", text),
("parse_mode", "MarkdownV2"),
];
let url =
reqwest::Url::parse_with_params(&format!("{TG_API_PREFIX}{TOKEN}/sendMessage"), ¶ms)?;
let response = reqwest::get(url).await?;
if !response.status().is_success() {
let error: ErrorResponse = response.json().await?;
let msg = "Cannot send message request";
if let Some(description) = error.description {
eprintln!("{msg}: {description}");
}
return Err(Box::from(msg));
}
let data: SendMessageResponse = response.json().await?;
if !data.ok {
eprintln!(
"Cannot send message: {}",
data.description
.unwrap_or_else(|| "error has no description".to_string())
)
}
Ok(())
}
Объединяем
В функции main
сначала запрашивается список всех программ (для сохранения их названий), далее запускается бесконечный цикл, который каждую секунду проверяет обновления со стороны телеграма, вызывая функцию handle_updates
, а также раз в примерно 10 минут проверяет обновления рейтингов и рассылает сообщения об обновлении. #[tokio::main]
нужно для старта main сразу как асинхронной функции, без необходимости ручного создания loop-а.
main
const TEN_MIN_IN_SEC: i32 = 10 * 60;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = init_db()?;
load_programs(&db).await.unwrap();
let mut offset = 0;
let mut sec_counter = 0;
loop {
offset = handle_updates(&db, offset).await.unwrap();
if sec_counter == 0 {
check_rating_updates(&db).await?;
}
sec_counter = (sec_counter + 1) % TEN_MIN_IN_SEC;
time::sleep(time::Duration::from_secs(1)).await;
}
}
check_rating_updates
async fn check_rating_updates(db: &DB) -> Result<(), Box<dyn std::error::Error>> {
// select registered watchers from 'results'
for c in db.select_all_competitions()? {
if let Some(case_number) = c.competition.case_number {
handle_competition(db, &c.tg_chat_id, &c.degree, &case_number, &c.program_id, false)
.await?;
}
}
Ok(())
}
Итоги
Надеюсь, этот бот поможет кому-нибудь ещё кроме меня. Ссылка на бота @uni_rating_checker_bot и на исходный код
P.S.
Хотелось бы проверить, как приложение справится под нагрузкой (сервер довольно слабенький)
Из возможных фич - учитывать, что кто-то из тех, кто находится выше по рейтингу, уже проходят на направление, которое у них выше по приоритету
После написания основной части статьи я переписал часть проекта, улучшив передачу ошибок наверх и их обработку
Только магистратура потому что для бакалавриата вроде как приходит другая структура данных, а времени особо не было на это
По хорошему, ввод данных надо делать не передачей нескольких аргументов в одной строке, а чтобы пользователь мог ввести все по очереди, в идеале - чтобы надо было просто выбирать из предложенных вариантов, например, с помощью кнопок
Вдохновился этой статьей https://habr.com/p/679832
Команда потестить:
/watch itmo master 15850 M1-0979-2022
. Это не я :) Выбрал наугад
Комментарии (11)
alex_eyk
09.08.2022 18:23+1Бот для поступления это действительно круто и удобно! В прошлом году тоже делал телеграм бот для поступления, но на бакалавриат в МГТУ им. Баумана. Если кому-то понадобиться, правда, уже к следующему учебному году, то вот он: https://github.com/alex-eyk/bmstu-competitions-bot-spring
rickshaw Автор
09.08.2022 22:31Спасибо всем кто пишет! Я забыл указать ссылку, reqwest - это название библиотеки: https://crates.io/crates/reqwest
Akter
10.08.2022 13:24+1Скорее всего ваш бот поможет кому-то в качестве примера/вдохновения, что тоже замечательно. Скажите пожалуйста, почему выбрали Rust? Был курс по нему или самостоятельно изучаете?
rickshaw Автор
10.08.2022 13:39+2Изначально я его изучил, чтобы написать на нем wasm-модуль для расчета множества Мандельброта для ВКР, и в процессе изучения он мне понравился. Потом понравился еще больше) Изучал сам, курсов сейчас по нему возможно нету, хотя конечно я и не искал особо.
Причины, почему понравился, возможно пишут много где, так что я тут вряд-ли скажу что-либо новое - компилируемый/быстрый, много библиотек (по крайней мере пока достаточно), пакетный менеджер, безопасность/крутые подсказки прямо в редакторе что ты что-то забыл обработать, может что-то ещё забыл.
bullinchik
10.08.2022 20:02+1Есть уже курсы, на различные крупных площадках курсов
rickshaw Автор
10.08.2022 20:18Тогда здорово! Хотя я сейчас посмотрел на некоторых крупных, которых вспомнил (geekbrains, skillfactory, skillbox, яндекс практикум), там не нашел курсов по rust. Видел недавно https://habr.com/p/677978, но это не крупная площадка (по крайней мере пока что)
Dirlandets
10.08.2022 13:24+2Круто видеть, что раст становится языком, в том числе небольших проектов
blood_develop
11.08.2022 14:09+3Круто видеть, что раст выбирают студенты вместо питонов и прочих "простых для изучения"
LuggerFormas
Модератор Вас прокатил...