# Пишем телеграм бота на Rust и tbot
Недавно в телегу добавили три минигры (кости, дартс и баскетбол).
В этом туториале мы напишем бота который поможет подсчитывать баллы
и определять победителя.
## Основа
Писать мы будем на Rust и tbot.rs. Начнем с зависимостей —
`tokio` и `tbot`.
```toml
[dependencies]
tbot = "0.7"
tokio = { version = "0.2", features = ["macros", "sync"] }
```
А вот и основа нашего будущего бота.
```rust
use tbot::prelude::*;
#[tokio::main]
async fn main() {
let mut bot = tbot::from_env!("BOT_TOKEN").event_loop(); // 1
bot.help(|context| async move { // 2
context.send_message_in_reply("Какой-то хелп мессаг")
.call() // 3
.await
.unwrap();
});
bot.polling().start().await.unwrap(); // 4
}
```
<details>
<summary>Разберём выделенные строчки</summary>
<!-- TODO: ссылки на доки -->
1. `tbot::from_env!` - макрос, который берёт токен бота из переменной
окружения на этапе компиляции и создаёт бота с этим токеном. Ещё есть
`tbot::Bot::from_env`, который берёт токен во время выполнения.
`mut` нужен для добавления обработчиков.
2. На этой строчке задаётся обработчик для команды `/help`. В него
передаётся контекст - структура с информацией о событии и методами,
которые выводят некоторые параметры из контекста. Например,
`send_messsage_in_reply` выводит `chat_id` и `reply_to_message_id`.
3. У методов есть необязательные параметры, которые можно задать с помощью
Builder API. Поэтому, чтобы вызвать метод, его надо превратить во `Future`
с помощью метода `call`. Когда `.await` будет поддерживать `IntoFuture`,
явный вызов `call` будет не нужен.
</details>
В строчке (4) мы запускаем бота с помощью polling. Для long polling можно
вызвать метод `timeout` перед `.start`. Но tbot из коробки ещё поддерживает
webhooks по HTTPS и по HTTP (в случае HTTP нужна прослойка, например
nginx), но для туториала нам достаточно polling.
Ещё одной полезной фичей tbot является поддержка HTTP(S) и SOCKS5 прокси.
В [этом примере](https://gitlab.com/SnejUgal/tbot/-/blob/master/examples/proxy.rs)
можно посмотреть, как настраивается прокси.
## Добавляем кнопки
Теперь научим бота отвечать на `/start`. В ответ будем отправлять пользователю сообщение с тремя
кнопками — выбором типа игры. Добавим в код бота следующие строчки.
```rust
use tbot::{
prelude::*,
types::keyboard::inline::{Button, ButtonKind}
};
const KEYBOARD: &[&[Button]] = &[
&[
Button::new("Кости", ButtonKind::CallbackData("dice")),
],
&[
Button::new("Дартс", ButtonKind::CallbackData("darts")),
],
&[
Button::new("Баскетбол", ButtonKind::CallbackData("basketball")),
],
];
```
> Здесь мы создаем клавиатуру из трёх рядов по одной кнопке в каждом. `ButtonKind` определяет тип кнопки.
> Кнопки с типом `CallbackData` по нажатию отправляют callback-событие
> с указанной строкой.
```rust
let mut bot = ..;
bot.start(|context| async move {
context.send_message_in_reply("Выбери мини-игру")
.reply_markup(KEYBOARD)
.call()
.await
.unwrap();
});
```
Теперь нам надо обработать callback. Для этого есть
`EventLoop::message_data_callback`.
```rust
bot.message_data_callback(|context| async move {
context.ignore().call().await.unwrap();
let data = context.data.as_str();
let message = match data {
"dice" => "Бла бла бла дайс зе бест",
"darts" => "Бла Бла Бла дартс тоже норм",
"basketball" => "Бла Бла сыграем в 33",
_ => return,
};
context
.send_message_in_reply(message)
.call()
.await
.unwrap();
});
```
<!-- > Так как `data_callback` обрабатывает кнопки и обычных сообщений и инлайновых,
> контекст `DataCallback` не имплементит `ChatMethods` поэтому мы уже не можем
> использовать `send_message_in_reply`, но мы можем сымитировать этот метод
> с помощью метода `Bot::send_message` и `Builder API` стракта `SendMessage`. -->
## Знакомство со стейтами
Все отлично, но нам надо где-то хранить выбор пользователя. И для этого в
tbot есть `StatefulEventLoop`. От обычного ивент лупа он отличается тем, что
в каждый хендлер вторым параметром передается стейт. Напишем стракт который
будет хранить все необходимые поля.
```rust
enum GameKind {
Dice,
Darts,
Basketball,
}
struct State {
game_kind: GameKind,
}
```
Теперь немного перепишем бота.
```rust
use tokio::sync::Mutex;
use tbot::{
state::Chats,
// ..
};
// ..
let mut bot = tbot::from_env!("BOT_TOKEN").stateful_event_loop(
Mutex::new(Chats::new())
);
```
> `state::Chats` - спецаильный стракт для хранения состояния чата - по сути
> обертка над `HashMap` гле ключ пара ChatId а значение любой тип.
> `state::messages::Messages` имеет схожее с `HashMap` API.
```diff
- bot.help(|context| async move {
+ bot.help(|context, _| async move {
- bot.start(|context| async move {
+ bot.start(|context, _| async move {
```
> Так как у нас теперь `StatefulEventLoop` у каждого хендлера должно быть
> два параметра. В обработчиках `/start` и `/help` нам не нужно получать и
> изменять стейт, поэтому достоаточно переписать так.
>
```rust
bot.message_data_callback(|context, state| async move {
context.ignore().call().await.unwrap();
let data = context.data.as_str();
let (message, game_kind) = match data {
"dice" => ("Бла бла бла дайс зе бест", GameKind::Dice),
"darts" => ("Бла Бла Бла дартс тоже норм", GameKind::Darts),
"basketball" => ("Бла Бла сыграем в 33", GameKind::Basketball),
_ => return,
};
{
let mut state = state.lock().unwrap();
state.insert(&*context, State { game_kind });
}
context
.send_message_in_reply(message)
.call()
.await
.unwrap();
});
```
> `Chats::insert` ничем не отличается от метода `HashMap::insert`
> он присваивает указанному чату переданный стейт.
## Предикаты спешат на помощь
Теперь выбор пользователя сохраняется в стейт, но повторное нажатие кнопки
перезапишет его. Также тип игры может выьрать любой участник, хотя в теории
это должен делать тот кто отправил `/start`. Давайте поправим это!
```rust
use tbot::{
types::{
user,
// ..
}
};
struct State {
owner: user::Id,
game_kind: Option<GameKind>,
}
```
> Первым делом добавим в стейт поле `owner` - там мы будем хранить владельца
> текущей сессии игры. Так же поменяем тип `game_kind` на `Option`, так как
> при обработке `/start` мы еще не будем знать, тип игры, но положить что-то в
> стейт надо.
```rust
bot.start(|context, state| async move {
let from = match &context.from {
Some(user) => user,
None => return,
};
{
let mut state = state.lock().unwrap();
state.insert(
&*context,
State {
owner: from.id,
game_kind: None,
},
);
}
context
.send_message_in_reply("Выбери мини-игру")
.reply_markup(KEYBOARD)
.call()
.await
.unwrap();
});
```
Теперь надо в обработчике `message_data_callback` проверять кто нажал
на кнопку и в зависимости от этого либо показывыть сообщение об ошибке,
либо записывать выбор в стейт. Мы могли бы сделать это с помощью `if-else`
внутри хендлера, но лучше сделать это с помощью предикатов. Предикаты это функции
возвращающие булев. В tbot предикаты используются в `_if` хендлерах. От обычных
они отличаются тем что первым параметром принимают предикат.
```rust
use std::sync::{Arc, Mutex};
use tbot::{
/// ..
contexts::MessageDataCallback,
};
async fn is_owner(context: Arc<MessageDataCallback>, state: Arc<Mutex<Chats<State>>>) -> bool {
let state = state.lock().unwrap();
let owner = match state.get(&*context) {
None => return false,
Some(state) => state.owner,
};
owner == context.from.id
}
```
> Предикаты так же как и хенделры принимают контекст и стейт
> (для `StatefulEventLoop`).
Теперь напишем еще один более простой предикат, который в будущем нам поможет.
```rust
async fn is_game_kind(context: Arc<MessageDataCallback>) -> bool {
matches!(context.data.as_str(), "dice" | "darts" | "basketball")
}
```
> Как вы могли заметить этот предикат принимает только контекст так как стейт нам не нужен.
Ну и перепишем обработчик `message_data_callback`
```rust
use tbot::{
// ....
predicates::{without_state, StatefulPredicateBooleanOperations}
};
bot.message_data_callback_if(
is_owner.and(without_state(is_game_kind)),
|context, state| async move {
context.ignore().call().await.unwrap();
let data = context.data.as_str();
let (message, game_kind) = match data {
"dice" => ("Бла бла бла дайс зе бест", GameKind::Dice),
"darts" => ("Бла Бла Бла дартс тоже норм", GameKind::Darts),
"basketball" => ("Бла Бла сыграем в 33", GameKind::Basketball),
_ => return,
};
{
let mut state = state.lock().unwrap();
let mut state = state.get_mut(&*context).unwrap();
state.game_kind = Some(game_kind)
}
context.send_message_in_reply(message).call().await.unwrap();
},
);
```
> Сначала разберем `is_owner.and(without_state(is_game_kind))`.
> В tbot есть трейт `StatefulPredicateBooleanOperations` который помогает
> комбинировать предикаты, благодаря нему мы можем с легкостью выполнять простые
> булевы операции с предикатами. `without_state` еще один хелпер из
> `tbot::predicates`. Так как `StatefulEventLoop` ожидает что все предикаты
> принимают вторым параметром стейт, нельзя просто так взять и передать предикат
> с одним аргументом. `without_state` оборачивает переданный предикат,
> создавая новый - принимающий два аргумента. Новый предикат просто игнорирет
> стейт и вызывает внутренний всего лишь с одним аргументом - контекстом.
>
> `let mut state = state.lock().unwrap();` так какв предикате у нас есть
> проверка на то что стейт существует, мы вполне спокойно можем анврапить.
туду добавить предикат для `/start` который не даст перезаписать стейт если
он уже есть.
## Какое то продолжение 100 лет спустя.
Боты в телеграмме не могут получить список всех участников чата, но по задумке
игра завершается после того как каждый участник сделал бросок. Поэтому после
выбора типа игры имеет смысл прислать сообщения с другими
кнопками - "Присоединиться" и "Начать игру".
```rust
const SECOND_KEYBOARD: &[&[Button]] = &[
&[Button::new("Присоединиться", ButtonKind::CallbackData("join"))],
&[Button::new("Начать игру", ButtonKind::CallbackData("start"))],
];
```
> Добавим макет второй клавиатуры