Building & Deploying A Down Detector Telegram Bot in Rust

·

16 min read

In this article, we are going to have a look at building a Telegram bot using Rust that can store websites from users, periodically sends HTTP requests to stored webpage links and will report back to users if the webpages are up or down, based on given command arguments. Using Telegram's extensive bot API with Rust allows us to effectively (and easily!) create powerful web services that can utilise SMS notifications without having to go through companies like Vonage. No knowledge of Rust is required to follow this tutorial - however, it will be helpful if you've done some programming before following.

We'll also be deploying our web service on shuttle, an upcoming Rust-native cloud development platform that has a generous free tier and is currently an ideal choice for deploying with Rust code, as it requires zero config and has several additional libraries that support things like static folders, persisted data as well as allowing us to create our own service (which we will be looking at in this article).

Looking for an example of the final code? You can find the GitHub repo for this article here.

Setting Up

Before we get started, you'll want Rust installed on your machine. If you don't have it installed, you can download it here. This will install Rust as well as Cargo, which is Rust's build packager and dependency manager. You'll more than likely also need Docker as running the final app in local deployment uses Docker to provision a Postgres database for you. You can download Docker here.

To be able to make a Telegram bot, we will need to obtain an API key from Telegram (more about this here) and store it in a file called Secrets.toml which will be used by the shuttle_secrets library in order to be able to initialise the bot itself. This is essential for the bot to work since otherwise, we can't authorise our web app and Telegram won't allow us to do anything.

You will need to install shuttle's command-line app (use cargo install cargo-shuttle) and use cargo shuttle login followed by the API key or you can copy and paste the login command from logging into the shuttle website to get started. Once this is done, we're ready to go!

Let's get started by initialising our project. Open up a terminal and do the following command:

cargo shuttle init --no-framework

You'll want to input the name of whatever you want to name your project, then it'll ask you where you want to create your directory. Since we're not using any particular web framework for this project, we've added the flag --no-framework.

Let's inspect the new files we created in our project folder:

[package]
name = "telegram-bot-rust"
version = "0.1.0"
edition = "2021"
publish = false

[lib]

[dependencies]
shuttle-service = { version = "0.9.0" }

The Cargo.toml file will hold a list of our dependencies, as well as other information about our project. We also have a Lib.rs file, although we can just delete everything from this file as we're not going to use any of the default code.

Let's add our required dependencies so that we won't need to keep adding them mid-way through the tutorial:

[dependencies]
reqwest = "0.11.14"
shuttle-secrets = "0.9.0"
shuttle-service = { version = "0.9.0", features = ["web-axum"] }
shuttle-shared-db = { version = "0.9.0", features = ["postgres", "sqlx"] }
sqlx = { version = "0.6.2", features = ["runtime-tokio-native-tls", "postgres"] }
teloxide = { version = "0.12.0", features = ["macros"] }
tokio = { version = "1.22.0"}

Database/SQL Setup

Before we start, we will quickly need to create our SQL schema so that we can start storing records. The easiest way to do this with SQLx is to install sqlx-cli which will allow us to use SQLx's command-line interface for running migrations easily and without hassle - you can install it by using the following command:

cargo install sqlx-cli

We can make a migrations file by running sqlx migrations add schema and it'll create an SQL file in a folder (named 'migrations') of our current working directory with the given date and then "schema" added after it. Let's add the following SQL to the file:

CREATE TABLE IF NOT EXISTS links (
    id SERIAL PRIMARY KEY,
    url VARCHAR NOT NULL,
    status VARCHAR NOT NULL,
    user_id VARCHAR NOT NULL
);

Now we can run migrations either from our code against a database connection or simply use sqlx migrations run followed by the --database-url flag and the database connection string.

Getting Started

Now that that's done, let's open up our Lib.rs and get to work. We'll start by creating the BotService struct (to be initialised in the main function), which will hold our bot and a reference to our database connection as we'll need to use both throughout this project:

pub struct BotService {
    pub bot: Bot,
    pub postgres: PgPool,
}

For reference: A struct is basically a way to bundle related data together. We're creating it here and deciding what types our different variables will be. Once we've created our struct, we'll need to actually initialise it, which you'll see below.

Once we've done that, we can continue with creating our entry point function for the whole thing to be able to work like so:

// a macro that allows shuttle to work
#[shuttle_service::main]
async fn init(
// holds secrets kept in Secrets.toml
    #[shuttle_secrets::Secrets] secrets: SecretStore,
// holds the database connection, which we pass into an initialised BotService struct for it to work
    #[shuttle_shared_db::Postgres] postgres: PgPool,
) -> Result<BotService, shuttle_service::Error> {
// run SQL migrations - comment this line out after as we only want this to run
// on the first time 
    sqlx::migrate!()
        .run(&postgres)
        .await
        .expect("ERROR: Couldn't carry out migrations");

// retrieve Telegram API key from Secrets.toml; if it doesn't exist, panic
// the bot won't work without this, so using .expect() is OK - however, in production typically you'll want to avoid this if you can use pattern matching for errors
    let teloxide_key = secrets
        .get("TELOXIDE_TOKEN")
        .expect("You need a teloxide key set for this to work!");

// return an initialised BotService struct - "return" is not required as returning is implicit in Rust
    Ok(BotService {
        bot: Bot::new(teloxide_key),
        postgres,
    })
}

In this code snippet, we've added a command to run our SQL migrations (our schema is idempotent as it'll only initialise the tables if they don't exist, so it should be safe from duplication), grabbed our Telegram API key from the Secrets file and then returned an initialised BotService struct with a new instance of Bot that uses our key, and our Postgres connection pool.

Now that we've set up our entry point function, we can now figure out how to implement BotService. Fortunately, shuttle provides us an easy way to add things to our web service that aren't supported out of the box. Let's have a quick look at what that looks like:

#[shuttle_service::async_trait]
impl shuttle_service::Service for BotService {
    async fn bind(
        mut self: Box<Self>,
        _addr: std::net::SocketAddr,
    ) -> Result<(), shuttle_service::error::Error> {

              self.start().await.expect("An error ocurred while using the bot!");

        Ok(())
    }
}

// adding functionality to BotService
impl BotService {
    async fn start(&self) -> Result<(), shuttle_service::error::CustomError> {
        let bot = self.bot.clone();
        let db_connection = self.postgres.clone();

        Ok(())
    }
}

In this code snippet, we're looking at an "impl" - or an Implementation. Implementations are extremely powerful in Rust, and we can use them to add functions to structs or enums. We can also implement Traits for structs and enums - which is what we're doing here. In this case, what we're doing is binding our custom service to a shuttle service trait so that when it's deployed, the bot will bind to the service and run. Self is a reference to the BotService struct itself - so because we've wrote an implementation of BotService that adds a start function, we can call self.start() to initialise the Telegram bot service.

So far, we've managed to successfully get our bot to initialise by binding itself to the service and initialising some variables - but what about bot commands? How can we give our bot commands to work with? We can simply use an enum combined with macros (which are incredibly powerful meta-programming utilities that expand into more code) provided by teloxide to quickly write what we want our bot to be able to do, like so:

#[derive(BotCommands, Clone)]
#[command(
    rename_rule = "lowercase",
    description = "These commands are supported:"
)]
enum Command {
    #[command(description = "display this text.")]
    Help,
    #[command(
        description = "Allow me to alert you when a website is down (or up!).",
        parse_with = "split"
    )]
    Watch { status: String, url: String },
    #[command(description = "Stop watching a webpage.")]
    Unwatch(String),
    #[command(description = "List all webpages that I'm watching for you.")]
    List,
    #[command(description = "Stop watching any webpages that you've asked me to watch for you.")]
    Clear,
}

If you need to add a command, you can simply do it by adding another Command macro and then adding the enum variant name underneath.

We can then set up a function that will respond to all of these enum variants by matching the input against the list of commands, like so:

async fn answer(bot: Bot, msg: Message, cmd: Command, db_connection: PgPool) -> ResponseResult<()> {
    match cmd {
        Command::Help => {
            bot.send_message(msg.chat.id, Command::descriptions().to_string())
                .await?;
        }
        Command::Watch { status, url } => match status.trim() {
            bot.send_message(msg.chat.id, "Watch!".to_string()).await?;
        },
        Command::Unwatch(url) => {
            bot.send_message(msg.chat.id, "Unwatch!".to_string()).await?;
        }
        Command::List => {
            bot.send_message(msg.chat.id, "List!".to_string()).await?;
        }
        Command::Clear => {
            bot.send_message(msg.chat.id, "Clear!".to_string())
                .await?;
        }
    }
    Ok(())
}

Currently, although we've matched all of our commands, trying to use any of them besides /help will simply make the bot shout the relevant command at us when we write any usable command in the chat. Simply put however, if we don't match all of the possible arguments, the program won't compile as any pattern matches in Rust must be exhaustive - this helps later down the line as it'll stop any unexpected errors from happening.

Now that we've created all of our absolutely necessary functions, we can link our answer function back to our start function in our BotService implementation like so:

async fn start(&self) -> Result<(), shuttle_service::error::CustomError> {
        let bot = self.bot.clone();
        let db_connection = self.postgres.clone();

        Command::repl(bot, move |bot, msg, cmd| {
            answer(bot, msg, cmd, db_connection.clone())
        })
        .await;

        Ok(())
    }

Here we define two variables that clone the BotService's bot and postgres values and then creates a closure inside Command::repl so that we can include our Postgres connection as normally the function will only take our bot, a Message type and the commands list.

Now that we have all of the initial parts set up - try using cargo shuttle run. You should be able to type /help in your bot's chat and it should respond with a list of commands that corresponds to what we've coded. Once you stop the local deployment the bot will stop working again, but if we deploy it again (whether locally or through a web deployment on shuttle), it will connect to the Telegram API and magically work again.

Now let's move onto creating our database functions!

To start with, let's create a Database.rs file and put our database-related functions in there. It's generally a good habit to not keep all of our code in one file as it can decrease readability by quite a lot, especially if you have to scour hundreds of lines of code just to find one function.

Let's add our function for creating a record:

pub async fn create_record(
    status: String,
    url: String,
    user_id: ChatId,
    connection: PgPool,
) -> Result<(), sqlx::Error> {
// if the user inputs a URL link without the http protocol behind it, add it to the start of the string - otherwise, our background task won't work as it relies on HTTP links
    let url = if url[0..4] == *"http" {
        url
    } else {
        format!("http://{url}")
    };

// insert a record into a table called "links", binding $1, $2 and $3 to respective variables
    sqlx::query("INSERT INTO links (url, status, user_id) VALUES ($1, $2, $3)")
        .bind(url)
        .bind(status)
        .bind(user_id.to_string())
        .execute(&connection)
        .await?;

    Ok(())
}

This function simply enriches the URL link if it doesn't start with "http", and then attempts to add a record to the database that contains the URL, whether we want to check if the website is up or down, and the chat ID (in direct messages, user ID and chat ID are the same thing) and then return OK(()) which means that the function was successful.

Now we'll add the function to delete a URL:

pub async fn delete_record(
    url: String,
    user_id: ChatId,
    connection: PgPool,
) -> Result<(), sqlx::Error> {
    sqlx::query("DELETE FROM links WHERE url = $1 AND user_id = $2")
        .bind(url)
        .bind(user_id.to_string())
        .execute(&connection)
        .await?;

    Ok(())
}

As you can see, this function at the moment simply attempts to delete a record that contains the URL that the user has passed to the bot, as well as the chat ID where the command was made (ie, you - if you're making the commands through direct messages).

Let's have a look at how you'd retrieve all of the data and process it so that you can tell the user what the bot is currently subscribed to at the moment:

pub async fn get_all_records(user_id: ChatId, connection: PgPool) -> Result<Vec<PgRow>, sqlx::Error> {
// make a query
    let query = sqlx::query("SELECT * FROM links WHERE user_id = $1")
        .bind(user_id.to_string())
        .fetch_all(&connection);

// if the query returns something, return the result
// if there's an error or there's nothing there, return an error
    match query.await {
        Ok(result) => Ok(result),
        Err(err) => Err(err),
    }
}

pub fn sort_data(data: Vec<PgRow>) -> Vec<Link> {
// create a vector of the Link struct 
    let mut records: Vec<Link> = Vec::new();

// for each row in the data, initialise a Link struct and push it to our list of records
    for row in data.iter() {
        let record = Link {
            id: row.get("id"),
            url: row.get("url"),
            status: row.get("status"),
        };

        records.push(record);
    }
// implicitly return records
    records
}

pub struct Link {
    pub id: i32,
    pub url: String,
    pub status: String,
}

Here we've created two functions: one for querying the database, and then one for sorting the data into a vector of strings. We've also created a struct as vectors can only typically hold one data type at any time - but in this case, we can make it hold a vector of structs as the struct itself is a data type.

Now that we've set up all of our command-related functions, let's update our answer function so that when we type a relevant command in, it'll do the correct action:

async fn answer(bot: Bot, msg: Message, cmd: Command, db_connection: PgPool) -> ResponseResult<()> {
    match cmd {
        Command::Help => {
            bot.send_message(msg.chat.id, Command::descriptions().to_string())
                .await?;
        }
        Command::Watch { status, url } => match status.trim() {
            "up" | "down" => {
                create_record(status, url, msg.chat.id, db_connection)
                    .await
                    .expect("Had an issue adding your submission :(");

                bot.send_message(msg.chat.id, "Successfully added your link.".to_string())
                    .await?;
            },
            _ => {
                bot.send_message(
                    msg.chat.id,
                    "You need to tell me if you want me to check if the website is up or down!".to_string()
                )
                .await?;
            }
        },
        Command::Unwatch(url) => {
            delete_record(url, msg.chat.id, db_connection)
                .await
                .expect("Had an issue unwatching {url}");

            bot.send_message(msg.chat.id, "Successfully unwatched.".to_string())
                .await?;
        }
        Command::List => {
            let records = get_all_records(msg.chat.id, db_connection)
                .await
                .expect("Had an issue getting any URLs");

            let sorted_data = sort_data(records);

            let data_to_strings = sorted_data
                .iter()
                .map(|record| {
                    format!(
                        "ID {}: {} - checking for {}",
                        record.id, record.url, record.status
                    )
                })
                .collect::<Vec<String>>();

            let data_to_strings = format!(
                "Here's the URLs you're currently watching: \n{}",
                data_to_strings.join("\n")
            );

// this function currently not created yet - we will make this in the next section
            send_message_without_link_preview(bot, msg.chat.id, data_to_strings)
                .await
                .expect("Oh no! There was an error sending a list message");
        }
        Command::Clear => {
            bot.send_message(msg.chat.id, "Hello world!".to_string())
                .await?;
        }
    }
    Ok(())
}

You may have noticed that a new function has been added to be able to send a message with the preview link URL disabled to save chat space, but we haven't actually created yet. We'll be creating that now - the process is quite simple: we initialise a SendMessage struct with all fields properly filled in, create a new JsonRequest using the bot and the struct, and then we send the request. Under the hood, the send_message() method utilises the SendMessage struct - all we're simply doing here is manually initialising it ourselves and then sending it. Let's have a look at what that would look like:

async fn send_message_without_link_preview(
    bot: Bot,
    user_id: ChatId,
    msg: String,
) -> Result<(), Box<dyn Error>> {
    let message_to_send = SendMessage {
        chat_id: user_id.into(),
        text: msg,
        disable_web_page_preview: Some(true),
        message_thread_id: None,
        entities: None,
        parse_mode: None,
        disable_notification: Some(false),
        protect_content: Some(true),
        reply_to_message_id: None,
        reply_markup: None,
        allow_sending_without_reply: Some(false),
    };

    let request = JsonRequest::new(bot, message_to_send);

    request.send().await.unwrap();

    Ok(())
}

As you can see, the SendMessage struct has a lot of different options, so if you want to further customise any of your messages by disabling notifications (or using parsing) for example, this allows you to do that.

Once we've added this in, the chatbot is done! We can now focus on creating the background task that will monitor the database, carry out HTTP requests and alert users.

How should the background task work? Well, at a basic level we'd want it to grab all of the database records, attempt to send a HTTP request to each URL, and if it matches the criteria we simply send a chat message to the user telling them the webpage is down (or up, depending). We also want it to continuously run in a loop and rest for a short amount of time between requests so that we don't get blocked by the websites we're trying to request. Let's have a look at what that would look like:

pub async fn start_monitoring(bot: Bot, db_connection: PgPool) -> Result<(), Box<dyn Error>> {
    loop {
        let records = sqlx::query("SELECT * FROM links")
            .fetch_all(&db_connection)
            .await?;

        for row in records.iter() {
            let url: String = row.get("url");
            let status: String = row.get("status");
            let user_id: String = row.get("user_id");

// create a new http client to make GET requests
            let reqwest_client = Client::new();
            let resp = reqwest_client.get(&url).send().await;

            match status.trim() {
// if the user wants to check whether the webpage is up, send a message if the webpage is reached
                "up" => {
                    if resp.unwrap().status().is_success() {
                        bot.send_message(user_id, format!("{url} is up!"))
                            .await
                            .expect("Had an error trying to send a message");
                    }
                }
// if the user wants to check whether the webpage is down, send a message if the webpage is unable to be reached
                "down" => {
                    if !resp.unwrap().status().is_success() {
                        bot.send_message(user_id, format!("{url} is down!"))
                            .await
                            .expect("Had an error trying to send a message");
                    }
                }
                _ => {}
            }
        }
// sleep for 2 minutes
        sleep(Duration::from_secs(120)).await;
    }
}

That's pretty much done! Now we just have to look at how we can put both of our services together.

Both the bot and the background task need to be awaited, so we should probably create a new asynchronous task through tokio to create a new thread (this is because if you try to await both in the bind function, one of them will never be reached). However, you'll probably notice that if you simply try to use tokio::spawn that if you try to use "self" in the thread and then try to use it again in the other function, you'll probably encounter an error about a value being moved (because of having to use async move when spawning the thread).

Thankfully, there's an easy and elegant solution to this: we can simply create an Arc of self, create a clone of the Arc and then we can safely use the value between threads. Let's see what that would look like in the bind function for our implementation of shuttle_service::Service for BotService:

#[shuttle_service::async_trait]
impl shuttle_service::Service for BotService {
    async fn bind(
        mut self: Box<Self>,
        _addr: std::net::SocketAddr,
    ) -> Result<(), shuttle_service::error::Error> {
        let self = Arc::new(self);

        let background_task = Arc::clone(&self);

        tokio::spawn(async move {
            Arc::clone(&self)
                .start()
                .await
                .expect("An error ocurred while using the bot!");
        });

        background_task.monitor().await?;

        Ok(())
    }
}

As you can see, we simply just need to declare a new variable that will clone the Arc and then we can use it in a thread, allowing us to run multiple tasks at once. Note that we can do this with other things, too: for example, if we wanted to add an Axum web server with static file support, we can simply add this to the list of tasks we want to run in parallel. This means that it's really simple to combine different tasks into web services with little hassle, which is great for us if we wanted to extend this app (for example: providing a web GUI via having a router).

Finishing Up

Now our web service is pretty much done! Once you're done testing that everything works, we can use cargo shuttle deploy and if everything works, it should deploy on shuttle, and now you (and other people!) can use your bot to alert webpage outages or whether a webpage has come back online.

If you'd like to extend this example, here are a couple of ways you could do that:

  • Use SMTP to send an e-mail when services are down

  • Add web server routing with Axum and static files using the shuttle_static_folder library