Salta al contenuto principale

Costruire un Bot Telegram in Rust con Teloxide

·7 minuti
Indice dei contenuti
Rust e una scelta insolita per un bot Telegram, ma i trade-off sono reali: un singolo binario linkato staticamente, immagini Docker sotto i 10MB e utilizzo di memoria che rimane stabile sotto carico. Teloxide e la libreria Telegram standard della community Rust e rende invisibile il plumbing async.

La maggior parte dei tutorial per bot Telegram sceglie Python o Node. Rust vale davvero la pena di essere considerato se ti importa della semplicita di deployment (un binario, nessun interprete), dell’impronta di memoria, o sei gia in un codebase Rust. Questo articolo illustra un bot completo usando teloxide: comandi, inline keyboard, una macchina a stati multi-step e deployment in produzione.

Setup
#

Crea un nuovo progetto e aggiungi le dipendenze al Cargo.toml:

Cargo.toml
[package]
name = "telegram-bot"
version = "0.1.0"
edition = "2021"

[dependencies]
teloxide = { version = "0.12", features = ["macros", "dialogues"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
pretty_env_logger = "0.5"
serde = { version = "1", features = ["derive"] }

[profile.release]
strip = true
opt-level = "z"  # Ottimizza per dimensione del binario
Nota

teloxide 0.12 richiede Rust 1.70+. La feature macros abilita il derive #[command]. La feature dialogues abilita l’API della macchina a stati. Entrambe sono necessarie per questo articolo.

Token del bot e configurazione dell’ambiente
#

Ottieni un token da @BotFather inviando /newbot. Salvalo in una variabile d’ambiente. Non hardcodificarlo mai.

.env (non committare questo file)
TELOXIDE_TOKEN=123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi

Caricalo all’avvio con std::env::var. teloxide legge anche TELOXIDE_TOKEN automaticamente se usi Bot::from_env().

Handler dei comandi con derive macro
#

Il derive BotCommands genera il parsing dei comandi e il testo di /help dai doc comment:

src/main.rs
use teloxide::{prelude::*, utils::command::BotCommands};

#[tokio::main]
async fn main() {
    pretty_env_logger::init();
    log::info!("Avvio del bot...");

    let bot = Bot::from_env();

    Command::repl(bot, answer).await;
}

#[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase", description = "Comandi disponibili:")]
enum Command {
    #[command(description = "Mostra questo messaggio di aiuto")]
    Help,
    #[command(description = "Saluta un utente per nome")]
    Hello(String),
    #[command(description = "Mostra l'ora attuale del server")]
    Time,
}

async fn answer(bot: Bot, msg: Message, cmd: Command) -> ResponseResult<()> {
    match cmd {
        Command::Help => {
            bot.send_message(msg.chat.id, Command::descriptions().to_string())
                .await?;
        }
        Command::Hello(name) => {
            bot.send_message(msg.chat.id, format!("Ciao, {}!", name))
                .await?;
        }
        Command::Time => {
            let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
            bot.send_message(msg.chat.id, format!("Ora del server: {now}"))
                .await?;
        }
    }
    Ok(())
}

Risposta a messaggi di testo arbitrari
#

Per i messaggi non-comando, usa un branch separato dell’handler. I dispatcher di teloxide compongono piu handler:

src/handlers/text.rs
use teloxide::prelude::*;

pub async fn handle_text(bot: Bot, msg: Message) -> ResponseResult<()> {
    if let Some(text) = msg.text() {
        if !text.starts_with('/') {
            bot.send_message(msg.chat.id, format!("Hai detto: {text}"))
                .await?;
        }
    }
    Ok(())
}

Inline keyboard con callback handler
#

Le inline keyboard allegano pulsanti a un messaggio. Ogni pulsante porta una stringa callback_data che arriva come aggiornamento CallbackQuery, separato dagli aggiornamenti Message:

src/handlers/keyboard.rs
use teloxide::{
    prelude::*,
    types::{InlineKeyboardButton, InlineKeyboardMarkup},
};

pub async fn send_menu(bot: Bot, chat_id: ChatId) -> ResponseResult<()> {
    let keyboard = InlineKeyboardMarkup::new(vec![
        vec![
            InlineKeyboardButton::callback("Opzione A", "opt_a"),
            InlineKeyboardButton::callback("Opzione B", "opt_b"),
        ],
        vec![InlineKeyboardButton::callback("Annulla", "cancel")],
    ]);

    bot.send_message(chat_id, "Scegli un'opzione:")
        .reply_markup(keyboard)
        .await?;
    Ok(())
}

pub async fn handle_callback(bot: Bot, q: CallbackQuery) -> ResponseResult<()> {
    // Rispondi sempre alla callback -- questo chiude lo spinner di caricamento.
    bot.answer_callback_query(&q.id).await?;

    let data = q.data.as_deref().unwrap_or("");
    let text = match data {
        "opt_a" => "Hai scelto l'Opzione A",
        "opt_b" => "Hai scelto l'Opzione B",
        "cancel" => "Annullato",
        _ => "Opzione sconosciuta",
    };

    if let Some(msg) = q.message {
        bot.edit_message_text(msg.chat().id, msg.id(), text).await?;
    }
    Ok(())
}
Importante

Chiama sempre bot.answer_callback_query(&q.id) quando gestisci una CallbackQuery. Se non lo fai, lo spinner nel pulsante Telegram continua a girare per 30 secondi, il che sembra un bug all’utente.

Macchina a stati con la feature dialogue
#

Le conversazioni multi-step (onboarding, form) necessitano di una macchina a stati. La feature dialogue di teloxide gestisce lo stato salvato in memoria (o Redis per la produzione):

src/registration.rs
use teloxide::{
    dispatching::dialogue::{self, InMemStorage},
    prelude::*,
};

type MyDialogue = Dialogue<State, InMemStorage<State>>;
type HandlerResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;

#[derive(Clone, Default)]
pub enum State {
    #[default]
    Start,
    AwaitingName,
    AwaitingAge { name: String },
}

pub async fn start(bot: Bot, dialogue: MyDialogue, msg: Message) -> HandlerResult {
    bot.send_message(msg.chat.id, "Benvenuto! Come ti chiami?")
        .await?;
    dialogue.update(State::AwaitingName).await?;
    Ok(())
}

pub async fn receive_name(bot: Bot, dialogue: MyDialogue, msg: Message) -> HandlerResult {
    let name = msg.text().unwrap_or("sconosciuto").to_owned();
    bot.send_message(msg.chat.id, format!("Piacere, {name}! Quanti anni hai?"))
        .await?;
    dialogue.update(State::AwaitingAge { name }).await?;
    Ok(())
}

pub async fn receive_age(
    bot: Bot,
    dialogue: MyDialogue,
    msg: Message,
    name: String,
) -> HandlerResult {
    let age: u32 = match msg.text().and_then(|t| t.parse().ok()) {
        Some(a) => a,
        None => {
            bot.send_message(msg.chat.id, "Per favore invia un numero.").await?;
            return Ok(());
        }
    };

    bot.send_message(
        msg.chat.id,
        format!("Registrato: {name}, eta {age}. Benvenuto!"),
    )
    .await?;
    dialogue.exit().await?;
    Ok(())
}

Gestione degli errori
#

teloxide usa LoggingErrorHandler per gli errori non gestiti per impostazione predefinita. Configura il dispatcher esplicitamente:

src/main.rs (configurazione dispatcher)
use teloxide::{
    dispatching::dialogue::InMemStorage,
    error_handlers::LoggingErrorHandler,
    prelude::*,
};

Dispatcher::builder(bot, schema())
    .dependencies(dptree::deps![InMemStorage::<State>::new()])
    .error_handler(LoggingErrorHandler::with_custom_text(
        "Si e verificato un errore nel dispatcher",
    ))
    .enable_ctrlc_handler()
    .build()
    .dispatch()
    .await;

Deployment come singolo binario
#

Compila un binario release e pacchettizzalo in un’immagine Docker minimale:

build release binary
cargo build --release
# Il binario si trova in target/release/telegram-bot (~5MB stripped)
Dockerfile
FROM debian:bookworm-slim AS runtime

RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*

COPY target/release/telegram-bot /usr/local/bin/telegram-bot

ENV RUST_LOG=info

CMD ["telegram-bot"]

Per un’immagine davvero minimale usando musl (binario completamente statico):

cross-compile to musl
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
Dockerfile (scratch-based)
FROM scratch
COPY target/x86_64-unknown-linux-musl/release/telegram-bot /telegram-bot
ENV RUST_LOG=info
ENTRYPOINT ["/telegram-bot"]

L’immagine scratch finisce a circa 7MB totali.

Il long polling e il default. Il bot apre una connessione HTTP di lunga durata ai server Telegram e riceve gli aggiornamenti man mano che arrivano. Funziona attraverso firewall e NAT, non richiede un IP pubblico ed e la scelta corretta per lo sviluppo e la produzione a basso traffico.

// Il long polling e il default con Bot::from_env() + Command::repl()
// Non e necessaria nessuna configurazione aggiuntiva.
let bot = Bot::from_env();
Command::repl(bot, answer).await;

I webhook richiedono un endpoint HTTPS pubblico. Telegram invia gli aggiornamenti al tuo URL invece che tu li faccia polling. Questo elimina la latenza del polling ed e meglio ad alta scala, ma richiede un certificato TLS e un IP raggiungibile.

use teloxide::dispatching::update_listeners::webhooks;

let addr = ([0, 0, 0, 0], 8443).into();
let url = "https://your-domain.com/webhook".parse().unwrap();

let listener = webhooks::axum(bot.clone(), webhooks::Options::new(addr, url))
    .await
    .expect("webhook setup fallito");

Dispatcher::builder(bot, schema())
    .build()
    .dispatch_with_listener(listener, LoggingErrorHandler::new())
    .await;
**Operazioni bloccanti negli handler async.** Gli handler di `teloxide` girano su un runtime Tokio. Chiamare operazioni bloccanti (I/O su file, `std::thread::sleep`, HTTP sincrono) dentro un handler affamera il runtime. Usa `tokio::time::sleep`, `tokio::fs`, o `tokio::task::spawn_blocking` per lavoro CPU-bound. **Non gestire le callback query separatamente dai messaggi.** Gli aggiornamenti `CallbackQuery` arrivano su un tipo di aggiornamento diverso da `Message`. Se il tuo dispatcher gestisce solo i messaggi, i tap sui pulsanti non fanno nulla e lo spinner del pulsante non si azzera mai. Registra un branch separato dell'handler per `Update::filter_callback_query()`. **Usare il long polling in produzione ad alta scala.** Il long polling va bene per traffico moderato, ma aggiunge latenza perche c'e sempre una richiesta in volo e Telegram raggruppa gli aggiornamenti. I webhook consegnano gli aggiornamenti immediatamente. Il punto di taglio e circa 100-200 aggiornamenti al secondo -- sotto quella soglia, il long polling e piu semplice e adeguato. **Mantenere lo stato in memoria di processo per deployment multi-istanza.** `InMemStorage` e per-processo. Se esegui due repliche, un utente in un dialogue sulla replica A perdera lo stato se instradato alla replica B. Usa `RedisStorage` del crate `teloxide-redis-storage` per deployment multi-istanza in produzione. **Non chiamare answer_callback_query.** Ogni `CallbackQuery` deve essere confermata con `bot.answer_callback_query(&q.id).await?` o Telegram mostra uno spinner di caricamento all'utente per 30 secondi.

Se vuoi approfondire questi argomenti, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architettura cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.