---
# System prepended metadata

title: Пишем телеграм бота на Rust и tbot

---

# Пишем телеграм бота на 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"))],
];
```
> Добавим макет второй клавиатуры