Building a Telegram bot in Rust: a journey through Songlify

Some time passed since the last article I wrote there. A lot of stuff happened meanwhile, especially with COVID, but here we are again. While busy dealing with the mess of real life tasks, three months ago I started to write a little bot for Telegram in Rust. It is a simple one, but I consider the journey interesting and worth of writing it down. If I add new features worth mentioning I will start a series about it, maybe.

Telegram bots

Telegram bots are not something new to me and nowadays are pretty much easy to make, so I consider them like a gym where to try out new technologies and experiment with stuff. I wrote plentiful of them, some of those are open source like for example https://github.com/Augugrumi/TorreArchimedeBot (which is currently broken 😭) that was useful when going to University, because it scraped the university free room web page and from there it was able to tell you which rooms where without any lessons and for how much time, allowing you to easily find a place where to study with your mates (yep, we didn’t like library too much).

A screenshot of
TorreArchimedeBot in action.

A screenshot of TorreArchimedeBot in action.

Also another one bot worthy of mention is https://github.com/Polpetta/RedditToTelegram, that allowed our D&D group to receive push notifications of our private Subreddit in our Telegram group.

As you can see, all of these bots are quite simple, but they have the added value of teaching you some new programming concepts, technologies or frameworks that can be later applied in something that can be more production environment.

Rust

I started to approach Rust many years ago (I do not remember exactly when). First interaction with it was quite interesting to say at least: there were way less compiler features (for example now the compiler is able to understand object lifetime at compile time most of the time alone, without specifying them) that made it a… not-so-pleasant programming experience. It had potential thought, so by following Rust news I picked it up last year again, noticing that now it has improved a lot and it is more pleasant to write. Meanwhile, also JetBrains developed a good support for IntelliJ, so now it is even possible to debug and perform every operation directly from your IDE UI.

Making the two worlds collide: Rust + Telegram

One of the features I wanted to learn this time regarding Rust was the asynchronous support it offers. Rust started to have async support with Tokio framework, and recently the Rust team started to build the asynchronous functionality inside Rust itself. Even if in the first steps, it looks promising and the idea of a low-level language, without GC, with automatic memory management and so much safety having asynchronous support is exciting to me! 🥳 So the only option left, at this point, was to start messing around with it. I started by picking up one of the many frameworks that provides a layer for the Telegram APIs, Teloxide. In particular, as you can see from its README, one of the examples starts by using #[tokio:main] macro:

use teloxide::prelude::*;

#[tokio::main]
async fn main() {
    teloxide::enable_logging!();
    log::info!("Starting dices_bot...");

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

    teloxide::repl(bot, |message| async move {
        message.answer_dice().await?;
        respond(())
    })
    .await;
}

This was the reason I picked it up, given that it looked the most promising by the time I started the project.

Building Songlify

So, after choosing what was going to use to build the bot, I needed a reason to build it.

With my friends we usually share a lot of songs (via Spotify links), so I thought it was a good idea to build a bot around it. I integrated a Spotify API library in it and started hacking up a bot.

⚠ Note that at the time of writing I have just noticed that the library I use for speaking with Spotify, aspotify has been deprecated in favour of rspotify

The first bot version was something very simple, and it was a single-file program with nothing very fancy (I have written it in a night):

use crate::SpotifyURL::Track;
use aspotify::{Client, ClientCredentials};
use teloxide::prelude::*;

enum SpotifyURL {
    Track(String),
}

fn get_spotify_entry(url: &str) -> Option<SpotifyURL> {
    if url.contains("https://open.spotify.com/track/") {
        let track_id = url.rsplit('/').next().and_then(|x| x.split('?').next());
        return match track_id {
            Some(id) => Some(SpotifyURL::Track(id.to_string())),
            None => None,
        };
    }
    return None;
}

struct TrackInfo {
    name: String,
    artist: Vec<String>,
}

async fn get_spotify_track(spotify: Box<Client>, id: &String) -> Option<TrackInfo> {
    match spotify.tracks().get_track(id.as_str(), None).await {
        Ok(track) => Some(TrackInfo {
            name: track.data.name,
            artist: track.data.artists.iter().map(|x| x.name.clone()).collect(),
        }),
        Err(_e) => None,
    }
}

#[tokio::main]
async fn main() {
    teloxide::enable_logging!();
    log::info!("Starting Songlify...");

    let bot = Bot::from_env().auto_send();
    teloxide::repl(bot, |message| async move {
        let spotify_creds =
            ClientCredentials::from_env().expect("CLIENT_ID and CLIENT_SECRET not found.");
        let spotify_client = Box::new(Client::new(spotify_creds));

        log::info!("Connected to Spotify");
        let text = message.update.text().and_then(get_spotify_entry);
        match text {
            Some(spotify) => match spotify {
                Track(id) => {
                    let track_info = get_spotify_track(spotify_client, &id).await;
                    match track_info {
                        Some(info) => {
                            let reply = format!(
                                "Track information:\n\
                                Track name: {}\n\
                                Artists: {}",
                                info.name,
                                info.artist.join(", ")
                            );
                            Some(message.reply_to(reply).await?)
                        }
                        None => None,
                    }
                }
            },
            None => None,
        };
        respond(())
    })
    .await;

    log::info!("Exiting...");
}

As you can see, basically every time a request arrived to the bot, login to Spotify was performed and track information and name retrieved from there. Of course this was only the beginning (also you can “appreciate” the number of nested blocks there…). Now the bot supports albums and playlists too, with the possibility to go through each song in the playlist and collect general information such as how many artists are in that playlist, how many songs and other little information like that. If you see the bot repository you can see now that Spotify functions live in a separate module.

Packaging and distribution

The obvious choice for a software like that was to incorporate it into a OCI image. I wrote a very simple Dockerfile that, once the program was built, took the artifact and using the multi-stage Docker build functionality and put it into a separate container, in order to avoid having build dependencies inside the final image. I used the images distributed by the Distroless project (you can find the source on their Github repository) in order to obtain the smallest possible image. The final result?

λ ~/Desktop/git/songlify/ docker images
REPOSITORY               TAG                    IMAGE ID       CREATED          SIZE
test/test                latest                 8ac7a7018719   5 seconds ago    34MB
<none>                   <none>                 4bc7fb0699e0   12 seconds ago   1.53GB
rust                     1.56.1-slim-bullseye   d3e070c5ffa7   6 weeks ago      667MB
gcr.io/distroless/base   latest-amd64           24787c1cd2e4   52 years ago     20.2MB

A part from the 52 years old image pulled from gcr, you can see that test/test (actually Songlify) is only of 34MB. Not much if you consider that inside that image there are shared dynamic libraries to make the executable able to run, which by default weights 20MB. A plus of these images is that they do not run as root user and they do not have any shell of bash integrated, making a possible surface attack smaller (not that Docker is secure anyway…). I upload the images on Docker Hub, where you can find them here https://hub.docker.com/r/polpetta/songlify

Finally, to run the bot I use a very simple docker-compose definition, that can be found in my server-dotfiles repository. This allows me to easily upgrade the bot by just changing the version and running docker-compose up -d.

Plans for the future

Currently I work on the bot only when I feel like I wanna add something. An interesting feature to add could be to insert a persistence layer (using a database for instance) and add various stats (which is the most shared song? Who is that guy that shares the most songs in a group?). Persistence can be achieved quite easily by using Diesel, an ORM compatible with various databases.

Another cool feature could be to add the inline bot functionality, where you can search for songs directly in Spotify. Since I currently have a domain available for that I could set it up for receive web-hook notifications, instead of performing polling like the bot is currently doing (one requisite for inline bots is indeed to receive web-hooks). There are platforms like Heroku where you could make the bot run, but currently I prefer to use my box since it gives me more flexibility. Experimenting with Heroku could lead to cool results though 😏.

Finally, link translation could be something very useful. I have a friend that does not use Spotify but prefers to listen to music via YouTube. So an interesting feature would be, given a Spotify link, to “convert” it into a YouTube link and, of course, vice-versa. This could lead to translate playlists and albums too into YouTube-based playlists, which of course could be very useful if you are trying to avoid the infamous vendor lock-in, in this case being stuck with Spotify because all you music collection, saved songs, etc is there.