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 upondptree
, 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 inserde-json
and command-line arguments instructopt
.
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.
Select /start
to activate the bot. Then we select /newbot
and follow the instructions to create a bot.
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:
bot
is a bot, client for the Telegram bot API. It is represented via theRequester
trait.
handler
is anasync
function that takes arguments fromDependencyMap
(see below) and returnsResponseResult
.
listener
is something that takes updates from a Telegram server and implementsUpdateListener
.
cmd
is a type hint for your command enumerationMyCommand
: just writeMyCommand::ty()
. Note thatMyCommand
must implement theBotCommands
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 thehandler
: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
andMessage
.
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.
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