Serverless Telegram bot



I have a Telegram group where we share Finnish word puzzle results daily. The most popular game is Sanuli, a Finnish version of Wordle. Other games include Sanalouhos by Helsingin Sanomat and Sanapyramidi by Yle (the Finnish Broadcasting Company).

However, there is an issue when sharing the results of a Sanapyramidi game. The results output is quite verbose (13 lines), while the core result consists of just six lines of emojis and one empty line. To make matters worse, Telegram adds a massive link preview by default, doubling the message’s height. The whole message with the preview takes up too much screen real estate, and manually cleaning up the message is tedious and error-prone. It was time for yet another Telegram bot to do the boring work, enabling me to enjoy the word puzzles and share the results.

The final result

Two messages displaying Sanapyramidi results: without the bot and the shortened version using the bot.

Before and after.

Essentially, I wanted to filter out all rows that were not a part of the core result visual. In this case, filtering out all lines containing letters (A-Z) and trimming leading and trailing whitespace was enough. Here’s the code from the final implementation:

// Only keep lines without any letters
  const noAlphabetsRegex = /^[^a-zA-Z]*$/
  const outputText = inputText
    .split('\n')
    .filter(line => noAlphabetsRegex.test(line))
    .join('\n')
    .trim()

Challenging others to try the puzzle is one of the main reasons people share game results. With bots, it’s easy to create rich messages that contain buttons. I utilized those to include a button to navigate to the puzzle on Yle’s webpage and a button to start an inline query for @PyramidiBot, allowing users to conveniently share their own results after playing. While these take considerably less screen real estate than the default link preview, they make the otherwise short message substantially longer. Thus, the user can choose whether to send the message with or without the buttons.

Telegram's inline UI and the two resulting articles.

A screenshot from the inline UI provided by Telegram showing the two result variants to choose from: "Siivottu versio" (the default) and "Ilman nappeja" (without buttons).

Why go serverless?

Although I have used serverless computing services such as AWS Lambda and Azure Functions professionally, I’ve deployed my hobby projects on a VPS. It’s a reasonable approach, and since I run all services in containers orchestrated by Docker Compose, it is easy to manage multiple services on just one VPS instance. However, every service contributes to resource depletion and future maintenance overhead, including potential migration needs.

Since Telegram bots can be configured to receive updates via webhooks, I realized they could be built as serverless functions. When a webhook is registered for a Telegram bot, Telegram contacts the bot’s endpoint when updates occur. Webhooks are the key enabler for a serverless approach since, without them, the bot would have to poll Telegram’s servers for any new requests, meaning it would have to run constantly. The bot behavior will be very straightforward, and there’s no need for external modules such as databases. I only need the bot running when someone uses it; at other times, it can rest and not use any resources. Sounds like a perfect match for a serverless function!

Choosing the servers for serverless

As you probably already know, the “serverless” computing takes place on actual servers. However, as a developer, I don’t need to bother myself with that fact. I don’t have to worry about the underlying operating system or how to put my app into a container for deployment. Obviously, I still have to implement the business logic of the application. I must also properly configure the function so the service provider can deploy and run it.

This blog is running on Hetzner, but they are focused on servers. I would’ve liked to try another EU-based service provider, Stackit, but it seems like they don’t offer serverless functions. There are countless providers, and since I’ve already given DigitalOcean my credit card details, I decided to try their “Functions” product.

Implementation

Developing and deploying the serverless function

While I opted for DigitalOcean Functions, the principles are similar to those of any other serverless function provider. However, details vary.

  1. Create a namespace in DigitalOcean web panel.
  2. Install doctl command-line tool and connect to a namespace.
  3. Initialize, configure and deploy. I used nodejs:18 as the runtime.
  4. In the case of DigitalOcean Functions, the Javascript function that is invoked receives 0–2 parameters. In my case, the first parameter is called args, and it contains request details. Notably, query parameters and data from the body are directly accessible on the top level (e.g., args.myQueryParameter).
  5. Follow the return format described in the documentation.
  6. (Optional) Set up a billing alert. DigitalOcean provides tons of free invocations for a lightweight function like this, but I sleep better at night when I know I can react faster should there be a spike in invocations. Unfortunately, I didn’t see a billing limit option.
  7. Get the endpoint URL of the function:
    • Run doctl serverless functions list, and look for the function’s name (in my case, the name is webhook/update).
    • Run doctl serverless functions get <function's name> --url to get the endpoint URL.
    • We need the URL next when setting up the Telegram bot.

Setting up the Telegram bot

  1. Send @BotFather a message and create a new bot. Copy the API token into the clipboard.
  2. Send the command /setinline to BotFather to enable inline requests. This allows bot usage directly while composing a message, which is precisely what we need for this bot.
  3. Register the function as a webhook for Telegram by sending a setWebhook command to the bot API. That can be easily done with curl directly from the terminal:
curl https://api.telegram.org/bot<token>/setWebhook --json '{
  "url": "<endpoint URL of the function>",
  "allowed_updates": ["inline_query"]
}'

Make sure to replace <token> with the bot’s token and <endpoint URL of the function> with the actual endpoint where the function is invoked.

That’s it! Now, we can invoke our function via Telegram by beginning any message with our bot’s username. Message content is then passed as a parameter for the bot.

Developer experience issues with DigitalOcean Functions

Although getting the function to run was relatively straightforward, I encountered several annoyances.

Shadow bans, apparently?

When using the doctl command-line tool to deploy my function, I got the following error:

(main) Projects/pyramidi-bot % doctl sls deploy .
Error: Invalid project configuration file (project.yml): http request failed: GET https://***.doserverless.co/api/v1
    Learn more about the project configuration file https://docs.digitalocean.com/products/functions/reference/project-configuration/

“Fair enough, I must have messed up something while altering the project.yml”, I thought. After all, the error message is quite explicit. However, after a bit of trial and a lot of error, I realized that I’m unable to deploy their default hello.js function, with the default project.yaml. Someone on the forums said they got rid of the error after turning off their VPN. I double-checked that I’m not using one currently, but the problem persisted. However, when switching to mobile broadband, I could deploy the function. It seems like my fiber optic internet connection is somehow blocked by DigitalOcean. Funnily enough, turning on VPN actually allowed me to deploy as well.

This was quite bad for developer experience. First, I had to troubleshoot based on a misleading error message, just to discover that my IP address seems to be banned, without any hints on how to fix the solution. Maybe contacting their support would have been the solution, but then again, I could just choose another service provider for my next project if it’s still an issue.

Logging issues

Apparently, DigitalOcean will store the console output of only “non-blocking” functions. It seems like invocations through the endpoint URL are “blocking”. In other words, you can’t use console.log to debug DigitalOcean Functions invoked through the endpoint URL. This makes absolutely no sense to me and made development significantly more complicated than it needed to be. Potential solutions:

  1. Run the function as “non-blocking” when debugging. However, then the function’s return value is not included in the response.
  2. Include logs in the HTTP response, which is not that helpful when working with webhooks because you can’t really view the response.
  3. Forward logs to 3rd party service, although I’m not sure if it works with “blocking” invocations.
  4. Something else, there must be a better way than to…
  5. …log into an actual server, start an HTTP listener, and send the “logs” from the function with fetch.

With the last method, I could see what was going on inside the function, e.g., how the webhook from Telegram actually looks. That allowed me to get the function working, but it was by no means a proper solution for logging or debugging.

The good things

Despite the awkward issues described above, my overall experience with DigitalOcean Functions is positive. Once you overcome the challenges, the platform feels fast. Deploying is quick, and functions execute instantly. Once the function is there, it just works. I also like how straightforward the function configuration and deployment are compared to some bigger actors.

On Telegram and security

Although Telegram markets itself as a “secure” platform, messages are not end-to-end encrypted. While Telegram’s bot API is rich and powerful, I strongly recommend chatting on a truly secure platform with end-to-end encryption for all messages. Personally, I use Signal Messenger with friends and family whenever possible.

Source code & final thoughts

The source code is available on Github: samporapeli/pyramidi-bot.

The code consists of just two files:

The function implementation is just 61 lines long. If the currently unused log function and optional inline keyboard definition were omitted, the whole function would fit into 36 lines (and I’m not using compact code formatting tricks to achieve this).

Serverless solutions are often praised for easy scalability. While it’s true that this tiny function could also be scaled up effortlessly, it is by no means the main reason for using serverless functions in this case. Here are some key points regarding my experience with serverless functions for a tiny hobby project:

I will undoubtedly consider serverless approaches for future projects as well. Now do your daily word puzzle and share the results with someone 😁🧩



I'll announce new posts in the following channels:

See my blog's front page to read my other posts.

You can reach me on Mastodon: @sampo@hachyderm.io. I'd love to hear from you!