Building a Telegram Bot on Azure Function in Rust

In this tutorial, I'll go through the process of building a telegram bot in Rust, hosted in Azure Function. As an example, we build a bot to randomly pick a line from the message to help you, for example, decide what to eat for dinner.

Why Serverless

Azure Function is a serverless service. Unlike most of the service which charges according to the time your service is running, serverless charges for the time when you're actually processing an request. It's very suitable for the service that is called infrequently, for example, a personal Telegram Bot that only be used several times a day.

Why Rust

Besides that I love this language, Rust is also a perfect match for serverless. Serverless is charged by the duration the request is handled and the memory used in this duration. Rust is very efficient and the memory usage is very small.

Prepare

Create the project

  1. Open VSCode.

  2. Choose the Azure icon in the Activity bar, then in the Azure: Functions area, select the Create new project... icon.

  3. Choose a directory location for your project workspace and choose Select.

  4. Provide the following information at the prompts:

    • Select a language for your function project: Choose Custom.

    • Select a template for your project's first function: Choose HTTP trigger.

    • Provide a function name: In this example, we use pick-bot-func as the function name.

    • Authorization level: Choose Anonymous.

    • Select how you would like to open your project: Choose Open in current window.

    VSCode will create the project and open it.

  5. After the project is open, open the terminal from VSCode (or manually cd into the project), initialize a Rust project named handler.

    1
    cargo init --name handler
  6. In .gitignore, add handler and handler.exe.

  7. Open host.json.

    • In the customHandler.description section, set the value of defaultExecutablePath to the binary name handler (on Windows, set it to handler.exe).
    • In the customHandler section, add a property named enableForwardingHttpRequest and set its value to true.

Implements the API

  1. Open the project in your prefered editor/IDE if it is not VSCode. Note that you need this VSCode window latter for publishing.

  2. In Cargo.toml, add the following dependencies. This example uses actix-web framework.[1][2]

    1
    2
    3
    4
    5
    6
    7
    8
    9
    teloxide = { version = "0.7", default-features = false, features = ["macros", "auto-send", "rustls"] }
    log = "0.4"
    actix-web = "4"
    url = "2.2"
    anyhow = "1.0"
    serde = { version = "1.0", features = ["derive"] }
    serde_json = "1.0"
    getrandom = "0.2"
    afch-logger = "0.2"
  3. Here is an example implementation of src/main.rs.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    use actix_web::post;
    use actix_web::Responder;
    use actix_web::{web, App, HttpResponse, HttpServer};
    use std::env;
    use std::future::Future;
    use std::net::Ipv4Addr;
    use teloxide::adaptors::AutoSend;
    use teloxide::requests::{Requester, RequesterExt};
    use teloxide::types::Update;
    use teloxide::types::{MediaKind, MessageKind, UpdateKind};
    use teloxide::Bot;

    fn rand_u64() -> Result<u64, getrandom::Error> {
    let mut bytes = [0u8; 8];
    getrandom::getrandom(&mut bytes)?;
    Ok(u64::from_le_bytes(bytes))
    }
    fn pick<T>(items: &[T]) -> Result<&T, getrandom::Error> {
    // Not mathmatically with the same possibility, but good enough for our purposes
    let index = rand_u64()? % items.len() as u64;
    Ok(&items[index as usize])
    }

    // Log the error and simply return a 500
    async fn run_api(block: impl Future<Output = anyhow::Result<HttpResponse>>) -> HttpResponse {
    match block.await {
    Ok(res) => res,
    Err(err) => {
    log::error!("{}", err);
    HttpResponse::InternalServerError().body("Internal Server Error")
    }
    }
    }

    // The `pick-bot-func` should be the function name.
    #[post("/api/pick-bot-func")]
    async fn setup(body: web::Json<Update>, bot: web::Data<AutoSend<Bot>>) -> impl Responder {
    run_api(async move {
    let update = body.0;
    if let UpdateKind::Message(message) = update.kind {
    let chat_id = message.chat.id;

    if let MessageKind::Common(message) = message.kind {
    if let MediaKind::Text(text) = message.media_kind {
    if let Some(content) = text.text.strip_prefix("/pick") {
    let selections: Vec<&str> = content
    .lines()
    .filter(|line| !line.trim().is_empty())
    .collect();
    log::info!("selections: {:?}", selections);
    if selections.is_empty() {
    bot.send_message(chat_id, "Error: Message is empty or blank.")
    .await?;
    } else {
    bot.send_message(chat_id, *pick(&selections)?).await?;
    }
    } else {
    bot.send_message(chat_id, "Error: Message is not a /pick command.")
    .await?;
    }
    }
    }
    }

    Ok(HttpResponse::Ok().finish())
    })
    .await
    }

    #[actix_web::main]
    async fn main() -> anyhow::Result<()> {
    afch_logger::init();

    let port_key = "FUNCTIONS_CUSTOMHANDLER_PORT";
    let port: u16 = match env::var(port_key) {
    Ok(val) => val.parse().expect("Custom Handler port is not a number!"),
    Err(_) => 3000,
    };

    let bot = Bot::from_env().auto_send();

    log::info!("Starting server on port {}", port);
    HttpServer::new(move || {
    App::new()
    .app_data(web::Data::new(bot.clone()))
    .service(setup)
    })
    .bind((Ipv4Addr::UNSPECIFIED, port))?
    .run()
    .await?;
    Ok(())
    }

The key point is to handle the POST request of api/<function name> endpoint.

Local Testing

Before testing, you need to configure the environment. Open local.settings.json, in the Values section.

  • Add a property named TELOXIDE_TOKEN and set its value to your Telegram Bot token.
  • If you have any other config that read from environment variable, configure it in this section.

After you have configured, follow the following steps to test it locally.

  1. Build the project and copy the binary to project root.

    1
    2
    cargo build
    cp target/debug/handler .
  2. Run func start to run the azure function locally. When the function is setup, you can find the listening port in the output.

  3. In another terminal, run ngrok http <Function listening port>. After ngrok is setup, you can find the URL in the output.

  4. Open the following URL in your browser (or curl -L).

    1
    api.telegram.org/bot<Telegram Bot Token>/setwebhook?url=<ngrok URL>/api/<function name, `pick-bot-func` in this example>

    If you see the Webhook was set response, you have setup the webhook.

  5. Try to send a message to telegram bot. You should get the response.

Publish

In order to publish the function to Azure Function, you need to compile into a Linux executable, better to be musl to avoid glibc version issue. Because the teloxide depends on some libraries that makes cross-compiling tricky, here you can consider using GitHub Action for compiling and publishing.

First we need to publish our function from VSCode so that required resource is created. Note that the function won't work yet because it requires a Linux executable.

  1. If you are using Windows, change the defaultExecutablePath in host.json from handler.exe to handler. This instructs the function app to run the Linux binary.

  2. Add target to .funcignore file.

  3. If you aren't already signed in, choose the Azure icon in the Activity bar, then in the Azure: Functions area, choose Sign in to Azure....

    When prompted in the browser, choose your Azure account and sign in using your Azure account credentials.

    After you've successfully signed in, you can close the new
    browser window.

  4. Choose the Azure icon in the Activity bar, then in the Azure: Functions area, choose the Deploy to function app... button.

  5. Provide the following information at the prompts:

    • Select subscription: Choose the subscription to use. You won't see this if you only have one subscription.

    • Select Function App in Azure: Choose + Create new Function App (advanced).

    • Enter a globally unique name for the function app: Type a name that is valid in a URL path. The name you type is validated to make sure that it's unique in Azure Functions.

    • Select a runtime stack: Choose Custom Handler.

    • Select an OS: Choose Linux.

    • Select a hosting plan: Choose Consumption.

    • Select a resource group: Choose + Create new resource group.
      Enter a name for the resource group. This name must be unique within
      your Azure subscription. You can use the name suggested in the prompt.

    • Select a storage account: Choose + Create new storage account. This name must be globally unique within Azure. You can use the name suggested in the prompt.

    • Select an Application Insights resource: Choose + Create Application Insights resource. This name must be globally unique within Azure. You can use the name suggested in the prompt.

    • Select a location for new resources: For better performance, choose a region near you.The extension shows the status of individual resources as they are being created in Azure in the notification area.

  6. Find your newly created function under Azure Portal, then click it.

  7. Click Get publish profile to download the profile which will be used latter.

  8. In ths settings, click Configuration.

    • Delete WEBSITE_RUN_FROM_PACKAGE.

    • Click New application setting, enter WEBSITE_MOUNT_ENABLED as Name and 0 as Value, click Ok. [3]

    • Click New application setting, enter TELOXIDE_TOKEN as Name and the Telegram Bot token as Value, click Ok.

    • If you have other configurations via environment variable, set it here.

    • Click Save.

After the steps above, you have prepared the function resources. Now you can publish the function through GitHub Action.

  1. Create a GitHub Repo.

  2. Go to Settings > Secrets > Action.

  3. Click New repository secret, enter AZURE_FUNCTIONAPP_PUBLISH_PROFILE as Name, the content of the publish profile file you downloaded before as Value, click Add secret.

  4. Click New repository secret, enter AZURE_APP_NAME as Name, the function app name as the value [4], click Add secret.

  5. Add the following content in .github/workflows/release.yml file.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    name: Build and Release

    on:
    push

    jobs:
    release:
    name: Deploy to Azure Function
    runs-on: ubuntu-20.04
    steps:
    - name: Setup | Checkout code
    uses: actions/checkout@v2

    - name: Setup | Rust toolchain
    uses: actions-rs/toolchain@v1
    with:
    toolchain: stable
    profile: minimal
    target: x86_64-unknown-linux-musl

    - id: cargo-cache
    name: Cache
    uses: Swatinem/rust-cache@v1
    with:
    key: release-azure-function

    - name: Setup | musl tools
    run: sudo apt install -y musl-tools

    - name: Build | Tests
    run: cargo test --release --target x86_64-unknown-linux-musl -- --nocapture

    - name: Build | Build
    run: cargo build --release --target x86_64-unknown-linux-musl

    - name: Deploy | Move binary
    run: mv ./target/x86_64-unknown-linux-musl/release/handler .

    - name: Deploy | Azure Function
    uses: Azure/functions-action@v1
    id: fa
    with:
    app-name: ${{ secrets.AZURE_APP_NAME }}
    package: .
    publish-profile: ${{ secrets.AZURE_FUNCTIONAPP_PUBLISH_PROFILE }}
    respect-funcignore: true
  6. Push the code to the GitHub Repo, wait for the GitHub Action finish.

  7. At the azure function page, copy the URL.

  8. Open the following URL in your browser (or curl -L): api.telegram.org/bot<Telegram Bot Token>/setwebhook?url=<Azure Function URL>/api/<function name, pick-bot-func in this example>. If you see the Webhook was set response, you have setup the webhook.

Now you're Telegram Bot is hosted in Azure Function!


  1. We disable the default feature of teloxide so that openssl won't be a dependency, which is tricky to be cross-compiled to musl even under Linux. ↩︎

  2. The afch-logger is a logger implementation that uses some tricks to get expected log level on Azure. Check the repo for details. ↩︎

  3. The two steps above are to workaround Azure/functions-action/issues/81. ↩︎

  4. Note: this is NOT the function name. It's the app name you entered at step 5 of publishing function from VSCode. ↩︎