Crafting a Telegram bot with AWS Lambda (and Rust)

Crafting a Telegram bot with AWS Lambda (and Rust)

By Marco Antonio Garrido
· 2,915 words · 15 minutes reading time

In a previous post, I discussed how I reverse-engineered an API for obtaining QR codes for accessing my gym. Now, I'll go a step further and integrate that API into a simple Telegram bot that I'll host in AWS Lambda. Bear with me and I'll show you how.

This post assumes certain knowledge in Rust and its ecosystem. I consider it a really good programming language, focused on performance and memory and thread safety and it has a great documentation. I'll stop upselling it now and just mention that the code for the project can be found in this repo. Enjoy!

Implementing an adapter for the API

The core of this project is the API that I described in my previous post. As I commented there, I need to make a POST request in order to get an authentication token that I can then use in a GET request for obtaining the content of the QR code.

I personally feel like using Rust for this small project. So I'll start by modelling the response and request objects for the API:

#[derive(Serialize)]
struct AuthRequest<'a> {
    grant_type: &'a str,
    username: &'a str,
    password: &'a str,
    scope: &'a str,
}

#[derive(Deserialize)]
struct AuthResponse {
    access_token: String,
    expires_in: i32,
    refresh_token: String,
    scope: String,
    token_type: String,
}

#[derive(Deserialize)]
struct GetQRCodeResponse {
    #[serde(rename = "ExpiresAt")]
    expires_at: String,
    #[serde(rename = "ExpiresIn")]
    expires_in: String,
    #[serde(rename = "QrCode")]
    qr_code: String,
    #[serde(rename = "RefreshAt")]
    refresh_at: String,
    #[serde(rename = "RefreshIn")]
    refresh_in: String,
}

As the attributes in the structs show, I'll be using serde for serializing and deserializing them. The lifetime annotation in AuthRequest (i.e. 'a) is needed for what comes next. As the grant_type and the scope are always the same, it makes sense to have a LoginCredentials struct containing the username (or email) and the password and create the AuthRequest from it:

#[derive(Deserialize)]
pub struct LoginCredentials {
    pub email: String,
    pub password: String,
}

impl<'a> From<&'a LoginCredentials> for AuthRequest<'a> {
    fn from(value: &'a LoginCredentials) -> Self {
        Self {
            grant_type: "password",
            username: value.email.as_str(),
            password: value.password.as_str(),
            scope: "pgcapi offline_access",
        }
    }
}

Now the lifetime makes sense. We want to guarantee that the references to the email and password in the LoginCredentials outlive the AuthRequest instance. That's how Rust works. Let's make use of these structs in the adapter's logic:

fn fetch_auth_token(credentials: &LoginCredentials) -> Result<String, APIError> {
    let auth_url = "https://auth.puregym.com/connect/token";
    let client = Client::new();
    let body = serde_urlencoded::to_string(&AuthRequest::from(credentials))
        .map_err(|e| APIError::InternalError(e.into()))?;
    let response: AuthResponse = client
        .post(auth_url)
        .body(body)
        .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
        .header(AUTHORIZATION, "Basic cm8uY2xpZW50Og==")
        .send()
        .map_err(APIError::RequestError)?
        .json()
        .map_err(APIError::ResponseError)?;
    Ok(response.access_token)
}

pub fn fetch_qr_content(credentials: &LoginCredentials) -> Result<String, APIError> {
    let token = fetch_auth_token(credentials)?;
    let client = Client::new();
    let get_qr_url = "https://capi.puregym.com/api/v2/member/qrcode";
    let response: GetQRCodeResponse = client
        .get(get_qr_url)
        .header(AUTHORIZATION, format!("Bearer {}", token))
        .send()
        .map_err(APIError::RequestError)?
        .json()
        .map_err(APIError::ResponseError)?;
    Ok(response.qr_code)
}

pub fn generate_qr(credentials: &LoginCredentials) -> Result<Vec<u8>, APIError> {
    let qr_content = fetch_qr_content(credentials)?;
    let qr_code = QrCode::new(qr_content).map_err(|e| APIError::QRProcessingError(e.into()))?;
    let image = qr_code.render::<Luma<u8>>().build();
    let mut bytes = vec![];
    image
        .write_to(&mut Cursor::new(&mut bytes), image::ImageOutputFormat::Png)
        .map_err(|e| APIError::QRProcessingError(e.into()))?;
    Ok(bytes)
}

The first two functions represent the POST and GET requests that I mentioned before, while the third function generates a PNG image of the QR using qrcode. The image is returned as a vector of bytes. The only detail that I didn't mention yet is that the errors from serde, qrcode and reqwest (the HTTP client) are mapped into an enum called APIErrors. I'll make use of thiserror and anyhow crates for handling errors in a concise way:

#[derive(thiserror::Error, Debug)]
pub enum APIError {
    #[error("Failed to call PureGym API.")]
    RequestError(#[source] reqwest::Error),
    #[error("Failed to process response.")]
    ResponseError(#[source] reqwest::Error),
    #[error("Failed to create QR code.")]
    QRProcessingError(#[source] anyhow::Error),
    #[error("Something unexpected went wrong!")]
    InternalError(#[source] anyhow::Error),
}

And that's all we need for the API adapter. Let's keep this aside and let's talk about the bot.

Implementing the logic for the bot with Teloxide

Telegram has been supporting bots for years. A Telegram bot is just a small app that lives within the Telegram app. Users can interact with bots through commands, text and even more elaborated interfaces. Nowadays, one can even program games in Telegram. But for the sake of simplicity, let's stick to the original concept of bots being clients able to process messages from a group or a private chat and reply to them.

That's what I'll do, a bot that will get a command (/qr) and, in response, will connect to my gym's API and generate a QR code that will be sent back in an image. And all of this is accomplished through the Telegram Bot API.

One can go ahead and interact with the API directly or simply use one of the libraries wrapping its functionality. There are a lot of options out there, even within a single programming language. In my case, I'll use Teloxide, one of the frameworks for Rust.

All I need from Telegram is a token so I can authenticate my bot's requests to their API. That's done by interacting with BotFather, a bot provided by Telegram for creating and configuring bots (so meta!). Apart from the token, I need to store which email and password will be used for calling the gym's API. I decided to keep all in a json file called secrets.json which will have the following format:

{
  "bot_token": "<BOT_TOKEN>",
  "chat_credentials": {
    "<CHAT_ID_1>": {
      "email": "<EMAIL_1>",
      "password": "<PASSWORD_1>"
    },
    "<CHAT_ID_2": {
      "email": "<EMAIL_2>",
      "password": "<PASSWORD_2>"
    },
    //...
  }
}

The chat ids are the ids associated to the conversations from where the bot will receive the messages. In my case, I intend for this bot to only process requests from private chats with my wife and me, so the map will contain 2 entries. This is modelled in Rust in this way:

#[derive(thiserror::Error, Debug)]
pub enum BotSetupError {
    #[error(transparent)]
    SetupError(#[from] anyhow::Error),
}

pub type LoginCredentialsMap = HashMap<i64, LoginCredentials>;

#[derive(Deserialize)]
pub struct BotCredentials {
    pub chat_credentials: LoginCredentialsMap,
    pub bot_token: String,
}

impl BotCredentials {
    pub fn from_secrets() -> Result<Self, BotSetupError> {
        let secrets_path = "secrets.json";
        let mut file = File::open(secrets_path).context("Failed to open secrets file.")?;

        let mut file_content = String::new();
        file.read_to_string(&mut file_content)
            .context("Failed to read secrets file.")?;

        Ok(serde_json::from_str(&file_content).context("Failed to deserialize secrets.")?)
    }
}

BotCredentials represents the content of secrets.json and the from_secrets function takes care of loading the data from the file. As you can see, the chat_credentials are stored in a LoginCredentialsMap as defined in the previous section. Let's talk about the processing logic now:

#[derive(BotCommands, Clone, Debug)]
#[command(rename_rule = "lowercase")]
enum Command {
    QR,
}

async fn process_commands(
    bot: Bot,
    chat_credentials: Arc<LoginCredentialsMap>,
    msg: Message,
    cmd: Command,
) -> Result<(), anyhow::Error> {
    log::info!("Command {:?} received from chat {}", cmd, msg.chat.id);
    if let Some(login_credentials) = chat_credentials.get(&msg.chat.id.0) {
        log::info!("Processing command {:?} from chat {}", cmd, msg.chat.id);
        match cmd {
            Command::QR => {
                bot.send_message(msg.chat.id, "Calling API...").await?;
                let qr_code = generate_qr(login_credentials)?;
                bot.send_photo(msg.chat.id, InputFile::read(Cursor::new(qr_code)))
                    .await?;
            }
        }
    } else {
        log::info!("Ignoring command {:?} from chat {}", cmd, msg.chat.id);
    }
    Ok(())
}

As I mentioned before, there will be a single command QR modelled in the Command enum using the BotCommands trait from Teloxide. Every time that /qr is written in a chat with the bot, the function process_commands will be called and the bot will check if the chat id is in the chat_credentials, and if so, call generate_qr from the previous section and send the QR code through the Bot instance. Otherwise, the bot will ignore the command. Here, Bot is just a wrapper on Telegram's API for bots provided by Teloxide.

The logic is ready, let's wrap up the rest of Teloxide's configuration for the bot:

#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
    pretty_env_logger::init();

    log::info!("Fetching bot credentials.");
    let bot_credentials = BotCredentials::from_secrets()?;

    log::info!("Starting bot.");
    let bot = Bot::new(bot_credentials.bot_token);
    let chat_credentials = Arc::new(bot_credentials.chat_credentials);
    let mut dispatcher = get_dispatcher(bot.clone(), chat_credentials);

    dispatcher.dispatch().await;

    Ok(())
}

pub fn get_dispatcher(
    bot: Bot,
    chat_credentials: Arc<LoginCredentialsMap>,
) -> Dispatcher<Bot, anyhow::Error, DefaultKey> {
    Dispatcher::builder(bot, get_update_handler())
        .dependencies(dptree::deps![chat_credentials])
        .enable_ctrlc_handler()
        .build()
}

pub fn get_update_handler() -> UpdateHandler<anyhow::Error> {
    Update::filter_message()
        .filter_command::<Command>()
        .endpoint(process_commands)
}

The main function takes care of fetching the credentials and initializing Teloxide's objects. Apart from the already discussed Bot instance, there is a Dispatcher that takes care of fetching updates from Telegram's API (i.e. messages to the bot) and passing them to the UpdateHandler that filters the notifications that make sense for the bot (i.e. those containing a Command as defined before) and processes them through the process_commands function.

The default configuration already allows processing updates in parallel. While Teloxide is fully configurable in that regard, I'm fine with the current version of the bot and I could deploy it in a server if I want. But let's exhaust all the available options in the next section.

Polling vs Webhooks

The current version of the bot fetches updates by periodically calling Telegram's API. This approach is called polling. And it's great because it's really simple and it works everywhere as long as you have an internet connection. Feeling like hosting your bot in a private network (like in an office or at home)? Sure! Just keep it running on a computer. Or even when you are developing and testing a bot in your own workspace, it works!

So where's the caveat? First, you need to keep a connection with Telegram's API endpoint constantly. They are ok with it as long as you don't abuse it, so of course they will limit the number of open connections per bot to just one. For most use cases, this is fine but things get interesting when you need to scale your bot and keep multiple instances of it running in parallel. Even if there wasn't a limit on Telegram's side, polling updates from multiple instances requires some type of synchronization for avoiding things like processing the same message multiple times.

But no worries, Telegram offers an alternative. Instead of calling their API, they can call your bot every time they have an update for it. This is called a webhook. For this approach the bot needs to be reachable from the internet through a public IP, however, now, the requests from Telegram can be load-balanced across multiple running instances of the bot. There is no longer a permanent connection and the bot can now scale.

And good news, Teloxide also supports webhooks. The only change needed is in the update listener used by the Dispatcher.

pub async fn get_webhook_listener(
    bot: Bot,
    webhook_url: String,
) -> Result<impl UpdateListener<Err = std::convert::Infallible>, BotSetupError> {
    let addr = ([0, 0, 0, 0], 8443).into();
    let url = webhook_url
        .parse()
        .context("Failed to parse webhook URL.")?;
    Ok(
        webhooks::axum(bot.clone(), webhooks::Options::new(addr, url))
            .await
            .context("Failed to setup webhook listener.")?,
    )
}

This function returns an instance of an UpdateListener in axum. The listener will create a server in port 8443 and will call Telegram's API for registering the webhook (i.e. telling Telegram that requests to the provided URL will be processed by the bot). And this new listener can be used in the Dispatcher that was created in the main function:

/// A simple Telegram bot for generating QR codes for accessing PureGym
#[derive(Parser, Debug)]
#[command(about, long_about = None)]
struct Args {
    /// Get updates using webhooks instead of polling (default)
    #[arg(long, default_value_t = false)]
    webhook: bool,

    /// URL where the bot will get updates from the webhook
    #[arg(long, required_if_eq("webhook", "true"))]
    webhook_url: Option<String>,
}

#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
    let args = Args::parse();
    pretty_env_logger::init();

    log::info!("Fetching bot credentials.");
    let bot_credentials = BotCredentials::from_secrets()?;

    log::info!("Starting bot.");
    let bot = Bot::new(bot_credentials.bot_token);
    let chat_credentials = Arc::new(bot_credentials.chat_credentials);
    let mut dispatcher = get_dispatcher(bot.clone(), chat_credentials);

    if args.webhook {
        dispatcher
            .dispatch_with_listener(
                get_webhook_listener(bot.clone(), args.webhook_url.unwrap()).await?,
                LoggingErrorHandler::with_custom_text("Error from the update listener"),
            )
            .await;
    } else {
        dispatcher.dispatch().await;
    }

    Ok(())
}

To make things compatible with the polling version, I decided to make use of clap so the bot now accepts arguments from the command-line to define its behaviour. This version can be deployed into a web server running, for example, nginx and redirect all requests to a certain endpoint to the bot's listener in port 8443.

It is important to have a certificate in place in the web server as Telegram uses HTTPS for webhooks. Telegram can accept a self-signed certificate if it is provided when registering the webhook, but I will omit those details and focus on a different topic.

Going serverless with AWS Lambda

So far, all the options require a host of some type, either a server or a computer running the bot permanently. It would be great if we could abstract that so there is no need for maintaining a server nor having to worry about how to scale the bot when the time comes. And even better if we can do that for free at least until the bot gets popular. AWS Lambda is that option.

AWS Lambda is a serverless solution that runs code in response to events. In this case, the events are the HTTPS requests to the bot's webhook. But there is more. Nowadays lambdas support public URLs so you can handle requests directly in the code. No need for certificates nor having to set up an API in front of the lambdas, nothing, just the code.

Let's do that. Rust is supported through the Rust runtime for AWS lambda and the code can be easily compiled with Cargo Lambda. So I'll adapt first the main function:

#[tokio::main]
async fn main() -> Result<(), Error> {
    pretty_env_logger::init();
    log::info!("Fetching bot credentials.");
    let bot_credentials = BotCredentials::from_secrets()?;
    let chat_credentials = Arc::new(bot_credentials.chat_credentials);

    log::info!("Starting bot.");
    let bot = Bot::new(bot_credentials.bot_token);

    log::info!("Initializing handler dependencies.");
    let mut deps = DependencyMap::new();
    let me = bot.get_me().send().await?;
    deps.insert(me);
    deps.insert(chat_credentials);
    deps.insert(bot);
    let deps = Arc::new(deps);

    let handler = get_update_handler();

    run(service_fn(|event: Request| {
        function_handler(&deps, &handler, event)
    }))
    .await
}

pub async fn function_handler(
    deps: &Arc<DependencyMap>,
    handler: &UpdateHandler<anyhow::Error>,
    event: Request,
) -> Result<impl IntoResponse, Error> {
    let update = event.payload::<Update>()?.unwrap();
    log::info!("Update received: {:?}", update);

    let mut deps = deps.deref().clone();
    deps.insert(update);

    match handler.dispatch(deps).await {
        ControlFlow::Break(Ok(())) => {}
        ControlFlow::Break(Err(err)) => {
            log::error!("Error from the UpdateHandler: {:?}.", err);
        }
        ControlFlow::Continue(_) => {
            log::warn!("Update was not handled by bot.");
        }
    }

    Ok((StatusCode::OK, ""))
}

Basically, I got rid of the Dispatcher and the UpdateListener and I extracted the necessary logic from them to process the events. Every request from Telegram will trigger function_handler. The Update is parsed from the payload in the request and added to the other dependencies (i.e. Bot instance, chat_credentials, etc.) and that's passed to the UpdateHandler which takes care of processing it as in the previous versions of the bot. The way to indicate which function needs to execute the lambda is by passing it as a service_fn at the end of the main function. The rest of the initialization in the main function is pretty much the same as in the previous sections.

We are ready to compile the code by executing cargo lambda build --release --compiler cargo --bin bot_lambda. This command will compile the binary bot_lambda, where I decided to put the code discussed above, and generate an x86 binary called bootstrap in target/lambda/bot_lambda. After creating an x86 lambda function in the AWS console, I can upload a ZIP file containing the binary and the secrets.json file and that's all concerning the AWS part.

Now, in order to register the webhook, I created the following script in Rust, using again clap for processing arguments:

/// Telegram bot webhook management for AWS lambdas
#[derive(Parser, Debug)]
#[command(about, long_about = None)]
struct Args {
    /// Enable webhook
    #[arg(long, default_value_t = false)]
    enable: bool,

    /// Disable webhook
    #[arg(long, default_value_t = true)]
    disable: bool,

    /// URL where the bot will get updates from the webhook
    #[arg(long, required_if_eq("enable", "true"))]
    webhook_url: Option<String>,
}

#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
    let args = Args::parse();
    pretty_env_logger::init();
    log::info!("Fetching bot credentials.");
    let bot_credentials = BotCredentials::from_secrets()?;
    log::info!("Initialize bot.");
    let bot = Bot::new(bot_credentials.bot_token);
    if args.enable {
        log::info!("Enabling webhook.");
        let url = args
            .webhook_url
            .unwrap()
            .parse()
            .expect("Unparsable webhook URL.");
        bot.set_webhook(url).send().await?;
    } else {
        log::info!("Disabling webhook.");
        bot.delete_webhook().send().await?;
    }
    Ok(())
}

I'm just missing to execute the following commands providing the URL associated to the lambda that I got from the AWS console:

cargo build --release --bin bot_management
target/release/bot_management --enable --webhook-url <LAMBDA_URL>

Conclusion

While some details can be refined, I hope this article covers the gist on how to create a Telegram bot in Rust and which things must be considered for hosting it. AWS Lambda is a great option for this kind of applications where the burden (or the pleasure) of maintaining a server is not really necessary.


About the author

Marco Antonio Garrido

Freelance Software Engineer

I'm a Full-Stack Software Engineer with more than 5 years of experience in the industry. After working 4 years as a SDE at Amazon, I decided to start a new chapter in my career and become a freelancer.

LinkedIn | GitHub