Building a Telegram bot with Rust.

For this article, we are going to build a Telegram bot that saves URLs address with Teloxide. The idea is a little bit silly and probably uncomfortable to use, but it will be useful for learning purposes.

This is not a comprehensive tutorial about Teloxide, we will just build a bot that saves and retrieves URLs from a Hashmap.

I'm a little fan of Telegram bots because they help me to build some backends projects ideas without the need to think about a UI.

What I want to achieve with this article is to continue learning Rust and to learn a little bit about Teloxide by building this bot.

Prerequisites:

  • Rust basic knowledge

  • Ngrok installed

We will use ngrok to be able to use webhook from our local machine. If you don't have ngrok, you can download it from here.

What's Teloxide?

According to its documentation:

A full-featured framework that empowers you to easily build Telegram bots using Rust. It handles all the difficult stuff so you can focus only on your business logic.

Highlights:

  • Declarative design. teloxide is based upon dptree, a functional chain of responsibility pattern that allows you to express pipelines of message processing in a highly declarative and extensible style.

  • Feature-rich. You can use both long polling and webhooks, configure an underlying HTTPS client, set a custom URL of a Telegram API server, do graceful shutdown, and much more.

  • Simple dialogues. Our dialogues subsystem is simple and easy-to-use, and, furthermore, is agnostic of how/where dialogues are stored. For example, you can just replace a one line to achieve persistence. Out-of-the-box storages include Redis, RocksDB and Sqlite.

  • Strongly typed commands. Define bot commands as an enum and teloxide will parse them automatically — just like JSON structures in serde-json and command-line arguments in structopt.

Directory structure

url_saver_bot/
  src/
    main.rs
    data.rs
  cargo.toml

Cargo.toml

[dependencies]
teloxide = { version = "0.11", features = ["macros", "webhooks-axum"] }
log = "0.4"
pretty_env_logger = "0.4"
tokio = { version =  "1.8", features = ["rt-multi-thread", "macros"] }
nanoid = "0.4"

BotFather

To create a Telegram bot, we need a token.

Go to the Telegram App and enter @BotFather in the search bar.

Screenshot-2022-12-16-092357

Select /start to activate the bot. Then we select /newbot and follow the instructions to create a bot.

Screenshot-2022-12-16-093337

We have to choose a name that the users will see and a username. Then the bot will send us a message with our token in it.

main.rs

use teloxide::{dispatching::update_listeners::webhooks, prelude::*, utils::command::BotCommands};

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

    let bot = Bot::from_env();

    let addr = ([127, 0, 0, 1], 8000).into();
    let url = "Your HTTPS ngrok URL here. Get it by `ngrok http 8000`".parse().unwrap();
    let listener = webhooks::axum(bot.clone(), webhooks::Options::new(addr, url))
        .await
        .expect("Couldn't setup webhook");

    Command::repl_with_listener(bot, answer, listener).await;

}

We are going to use webhooks and set three commands for this bot.

We create a Bot instance, it allows sending requests to the Telegram Bot API. We use from_env()function to read the Telegram token from the environment.

Then we define the port for our server. The value for the variable url will be the address we get when we initialize ngrok or if we want to deploy the bot, the address we get from the cloud service.

To initialize ngrok, we type the following command where ngrok is located, or if it is added to our PATH it will work too.

ngrok http 8000

If we go to localhost:4040, we will have our web interface.

The address shows it as the Forwarding HTTPS address is what we have to type as a value for the variable url.

We create a webhook instance and we pass addr and url as arguments.

We are going to use commands, so we use repl_with_listener and pass bot, answer, and listener as arguments; answer is a handler that we will define later.

This is what the documentation says about commands_repl_with_listener :

A REPL for commands, with a custom UpdateListener.

REPLs are meant only for simple bots and rapid prototyping. If you need to supply dependencies or describe more complex dispatch logic, please use Dispatcher. See also: “Dispatching or REPLs?”.

All errors from the handler and update listener will be logged.

Signature

Don’t be scared by many trait bounds in the signature, in essence they require:

  1. bot is a bot, client for the Telegram bot API. It is represented via the Requester trait.

  2. handler is an async function that takes arguments from DependencyMap (see below) and returns ResponseResult.

  3. listener is something that takes updates from a Telegram server and implements UpdateListener.

  4. cmd is a type hint for your command enumeration MyCommand: just write MyCommand::ty(). Note that MyCommand must implement the BotCommands trait, typically via #[derive(BotCommands)].

All the other requirements are about thread safety and data validity and can be ignored for most of the time.

#[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase", description = "These commands are supported:")]
enum Command {
    #[command(description = "display this text.")]
    Help,
    #[command(description = "Use this command to save a URL")]
    Save(String),
    #[command(description = "handle user's chat ID")]
    ChatId,

}

Here we define our bot's commands. The Help command will display the list of commands. The Save command will save a URL, and the ChatId command returns the user's chat ID.

async fn answer(bot: Bot, msg: Message, cmd: Command) -> ResponseResult<()> {
    let chat_id = msg.chat.id;

    match cmd {
        Command::Help => bot.send_message(msg.chat.id, Command::descriptions().to_string()).await?,
        Command::ChatId => {
            bot.send_message(msg.chat.id, format!("Your chat ID is {chat_id}")).await?
        }
        Command::Save(text) => {
            bot.send_message(msg.chat.id, format!("The URL you want to save is: {text}")).await?
        }
    };

    Ok(())
}

The answer function will handle the answers for every command. If the /help command is received, it will send the description of the commands. If /chatid is received it will send the message: "Your chat Id is {chat_id}". If /url is received, it will send the message: "The URL you want to save is: {url}".

According to its documentation:

Handler arguments

teloxide provides the following types to the handler:

  • Message

  • R (type of the bot)

  • Cmd (type of the parsed command)

  • Me

Each of these types can be accepted as a handler parameter. Note that they aren’t all required at the same time: e.g., you can take only the bot and the command without Me and Message.

It will look like this when we start our bot:

Before we start our bot, we need to set the token:

# Unix-like
$ export TELOXIDE_TOKEN=<Your token here>

# Windows command line
$ set TELOXIDE_TOKEN=<Your token here>

# Windows PowerShell
$ $env:TELOXIDE_TOKEN=<Your token here>

Then we type in our command line:

cargo run

Now, we will implement the handlers to save and retrieve the URLs.

fn save_url(url: String) -> String {
    let new_id = &nanoid::nanoid!(6).to_string();

    let _url = url;

    format!("URL saved, the ID is {}", new_id)


}

Here the function receives a URL and generates an id string. Then it returns the id .

We need to add the save_url handler to the answer function.

async fn answer(bot: Bot, msg: Message, cmd: Command) -> ResponseResult<()> {
    let chat_id = msg.chat.id;

    match cmd {
        Command::Help => bot.send_message(msg.chat.id, Command::descriptions().to_string()).await?,
        Command::ChatId => {
            bot.send_message(msg.chat.id, format!("Your chat ID is {chat_id}")).await?
        }
        Command::Save(text) => {
            bot.send_message(msg.chat.id, save_url(text).to_string()).await?
        }
    };

    Ok(())
}

When we use the /save command and provide a URL, it will return an ID.

But this is not enough. We need something to store the URLs to retrieve them later.

To make it simple, we will store the URLs in a HashMap. We create a new file to define a model and the HashMap.

data.rs

use once_cell::sync::Lazy;

use std::sync::Mutex;
use std::collections::HashMap;


//use serde_json::json;

#[derive(Debug,Clone, Eq, Hash, PartialEq)]
pub struct StoredURL {
    pub id: String,
    pub https_address: String,
}

impl std::fmt::Display for StoredURL {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "{}", self.https_address)
    }
}

pub static DATA: Lazy<Mutex<HashMap<String, String>>> = Lazy::new(|| Mutex::new(
    HashMap::from([

    ])
));

Here we define StoredURL with the attributes id and https_addres. We implement Display for StoredURL to use the format! macro. Then we create a HashMap to store our URLs and be able to retrieve them by their ID.

Now we change our save_url handler to store the URLs.

fn save_url(url: String) -> String {
    let new_id = &nanoid::nanoid!(6).to_string();  

    let new_url= StoredURL{id:new_id.clone(), https_address:url};

    let mut data = DATA.lock().unwrap();

    data.insert(new_url.id, new_url.https_address);

    format!("URL saved, the ID is {}", new_id)

}

We add a command to retrieve a URL stored, we go back to the main.rs file.

#[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase", description = "These commands are supported:")]
enum Command {
    #[command(description = "display this text.")]
    Help,
    #[command(description = "Use this command to save a URL")]
    Save(String),
    #[command(description = "Use this command to retrieve a URL with its ID")]
    Get(String),
    #[command(description = "handle user's chat ID")]
    ChatId,

}

We will use the /get command when we want to retrieve a URL. Now we need a handler to do that job.

pub fn get_url(id:String) -> String {  

    let data = DATA.lock().unwrap();

    let url = data.get(&id);

     format!("{:?}", url)   
}

With get_url we will be able to retrieve any URL stored in DATA by its ID.

Then we add get_url handler to the answer function.

async fn answer(bot: Bot, msg: Message, cmd: Command) -> ResponseResult<()> {
    let chat_id = msg.chat.id;

    match cmd {
        Command::Help => bot.send_message(msg.chat.id, Command::descriptions().to_string()).await?,
        Command::ChatId => {
            bot.send_message(msg.chat.id, format!("Your chat ID is {chat_id}")).await?
        }
        Command::Save(text) => {
            bot.send_message(msg.chat.id, save_url(text)).await?
        }
        Command::Get(text) => {
            bot.send_message(msg.chat.id, get_url(text)).await?
        }
    };

    Ok(())
}

Let's change the code a little bit so the handler only returns the URL address.

pub fn get_url(id:String) -> String {

    let data = DATA.lock().unwrap()

    match data.get(&id) {
        Some(value) => format!("{}",value.to_string()),
        None => format!("There is not URL with this ID")

}

Conclusion

It was interesting and fun for me to build this bot. At first, I felt overwhelmed when trying to use Teloxide, it has a lot of features, but the documentation and the examples on its Github page were really helpful.

I tried to make this bot as simple as possible. But it will be good to add a database and a way to verify that the string passed is a URL before the bot saves it. A command to retrieve all the URLs saved and a command to delete a URL by its ID.

Thank you for taking the time to read this article.

If you have any recommendations about other packages, architectures, how to improve my code, my English, or anything; please leave a comment or contact me through Twitter, or LinkedIn.

The source code is here

References

Teloxide's GitHub Page

Teloxide Documentation

ngrok Documentation

Once_cell Documentation

How to Create a Telegram Bot using Python