Bot-tastic! How I Built and Deployed My Own Telegram Bot

July 11, 2024

Hey folks, it's been a while since I posted my last article. In this article, I am going to tell you how I built my own Telegram Bot and what I learned along the way.

Tech used:

  • TypeScript
  • OpenAI client: Why not use AI, right?
  • Railway.app: Server deployment
  • Ngrok: A port forwarder to expose your local port to the public internet. I use this for local testing and development purposes because the Telegram WebSocket API needs a public domain to send updates to.

Full code of the bot could be found here github.com/rifqimfahmi/random-tele-question-ts-public

Context

My partner and I were playing Would Your Rather Bot on Telegram, where it generates two playful options for all group members to choose from. At the end, the bot reveals the answer after everybody votes. I thought, "Hey, this is an interesting way to utilize the Telegram Bot API."

There are a lot of features available that a Telegram bot can offer compared to other messaging apps out there (like WhatsApp or Line), such as buttons, inline or custom keyboards, etc. So, I started researching how to make one.

The Bot

Bot QR

The idea of the bot is quite simple at first. When it receives a specific command, it replies with a random answer stored in a database and sends the question back to the chat, either private or group chat. The hope is that it will spark interesting discussions among the people in the chat.

The Bot in Action

Room-1

As I developed the bot, I wanted to try integrating it with other services. For example, I used the OpenAI API to generate an image using DALL-E 3 model upon user request. I also added a bot answer feature to try answering the questions it previously asked.

There was another bot I created utilizing DALL-E 3 model but I shut it down because we rarely use it anymore.

Room-2

Using OpenAI

I was curious about how to utilize the OpenAI API for some AI tasks, so I decided to use a couple of their models: DALL-E 3 and GPT-3-turbo.

  • DALL-E 3: Used for image generation based on the user message. The bot sends the generated picture back to the chat. https://openai.com/index/dall-e-3/
  • GPT-3: Used to allow the bot to join the conversation by replying to the questions it previously asked.

Choosing How to Listen for Updates

There are two methods for a bot on Telegram to listen for updates (incoming messages, message events, etc.):

  • Polling
  • Webhook

Polling

Polling is a technique where a client periodically checks a server for updates. The client sends regular requests to the server to see if any new information is available, such as new messages. Wikipedia

Webhook (Chosen)

Webhooks are user-defined HTTP callbacks that are triggered by specific events. When an event occurs, the server makes an HTTP request to a predefined URL, allowing for real-time communication between systems. Wikipedia

Using webhooks allows my app to avoid checking for updates periodically.

Choosing Programming Language

In most of my professional career, I have been using Java or Kotlin to build software. At first, I built the bot using Kotlin with Gradle. I tried using ktgbotapi to develop the client and [Tomcat Server Engine] from [KTOR] to deploy the WebSocket endpoint.

Recently, I have been looking to start a side project and learn some backend development. I learned some Go language, but I feel TypeScript or JavaScript is much faster for a small project to get deployed to the market. Additionally, you can develop both the frontend and backend in one language. So, I decided to use TypeScript instead.

Choosing the Bot Client Library

There are a lot of Telegram bot client libraries available out there (list of bot libraries). I decided to use grammY.

grammY has a lot of functionality and easy-to-follow examples. Their documentation is quite clear as well. They also have a list of projects from other developers, which are open-source and can be a good reference for me as I develop the bot. Two projects caught my attention:

From voicy, I learned about logging mechanisms using Pino, a good way to parse environment variables with znv, which utilizes Zod, and more.

Choosing the Database

At this point, I was looking for a persistent database that is simple and flexible to use for a small project. So, I decided to go with a NoSQL database, which is MongoDB.

Choosing the Web Framework

For the web framework for the webhook, I chose ExpressJS since I had previously used it for some learning.

Choosing the Server

I used to like Heroku because of its simplicity; you could just push your code to a remote branch, and it would automatically build and deploy your app. However, they don't have a free tier anymore, so I was looking for an alternative and decided to go with Railway.app.

It offers a free $5 trial, which is enough for me to keep the bot going for a while. It's quite intuitive to navigate the website, and it also has clear documentation.

Developing the Bot

I developed the bot using my favorite IDE, IntelliJ. It has a Database tool window, which makes it easy to interact with the database while developing the app.

TYPESCRIPT
app.ts
import express from "express";
import {webhookCallback} from "grammy";
import { v4 as uuidv4 } from 'uuid';
require('module-alias/register')
import {config} from "@/config/config";
import {bot} from "@/bot/bot";
import startMongo from "@/helpers/startMongo";
import {logger} from "@/logger";
async function runApp() {
const app = express();
const secretWebhookPath = uuidv4();
await startMongo()
app.use(express.json());
app.use(`/${secretWebhookPath}`, webhookCallback(bot, "express"));
app.listen(config.WEBHOOK_PORT, async () => {
await bot.api.setWebhook(`${config.DOMAIN}/${secretWebhookPath}`);
logger.info(`Bot webhook is up and running`);
});
}
runApp()

In the code snippet above, it shows how the server starts, initializing all necessary components such as the database and configuring the bot.

Now, you might wonder how to test this bot during local development since Telegram needs to send updates to a public domain. This is where Ngrok comes in.

Ngrok for Local Webhook Testing

If you look at the code snippet above, there's config.DOMAIN that will get the domain from the environment variable, which you can configure before starting the server.

You can't pass localhost as the webhook domain to the Telegram API, you need a public domain accessible through the internet. This is where ngrok comes in, allowing you to establish a secure way to expose local servers to the internet.

Ngrok is particularly useful for testing webhooks, demoing projects, and debugging HTTP traffic. You can follow the quickstart tutorial on their documentation page to get started. Once you install the ngrok command-line tool, it's quite straightforward to run it like so:

Bash
ngrok http 3000

The command above will generate a public domain url and forward the connection it received to my localhost:3000, where the Express app/webhook is listening. This allows me to develop and test the bot locally without needing to deploy a webhook server.

Putting Sensitive and Configuration Value on Environment Variable

Sometimes when you develop an app, there are parts that are interchangeable or different between local and remote servers. For example, in my case, the domain will be different when the app is deployed on the prod server. It will not use ngrok generated domain name on production. Other use cases include:

  • Storing an API key
  • Storing the database domain
  • Storing the deployment environment (staging|production)
  • Etc.

These are the environment variable entries for this Telegram Bot project.

.ENV
TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN
WEBHOOK_PORT=WEBHOOK_PORT
DOMAIN=YOUR_WEB_DOMAIN
MONGO_URL=mongo_url
OPENAI_TOKEN=token
NODE_ENV=dev|prod

You see Environment variables allow you to store sensitive information without exposing it to the internet. This is good for preventing accidental leaks of your API key to other people, which could risk exploitation.

I am also storing the database URL configuration in the environment variable because I don't want to hardcode the database URL. I use a local database when developing the app instead of a remote database so that the DB query does not rely on the internet which makes it faster during development.

Here is the env variable setup for this bot project

TYPESCRIPT
import {parseEnv} from "znv";
import * as process from "node:process";
import {z} from "zod";
require('dotenv').config();
function getConfig() {
const config = parseEnv(process.env, {
TELEGRAM_BOT_TOKEN: z.string(),
WEBHOOK_PORT: z.number().default(3000),
DOMAIN: z.string(),
MONGO_URL: z.string(),
OPENAI_TOKEN: z.string(),
NODE_ENV: z.enum(['dev', 'prod']),
})
return {
...config,
isDev: config.NODE_ENV == 'dev',
isProd: config.NODE_ENV == 'prod'
}
}
export type Config = ReturnType<typeof getConfig>;
export const config: Config = getConfig();

Spinning up the Database

As mentioned earlier, I am using MongoDB to store user data. There are a couple of TypeScript libraries that make interacting with MongoDB much easier and more intuitive, such as mongoose, mongoose-findorcreate, and @typegoose/typegoose.

There are several MongoDB providers online, such as MongoDB Atlas, which has a free tier sufficient for a small project. However, I am using Railway to deploy the remote MongoDB to keep everything together on one platform for simplicity.

Testing the Database locally with Docker compose

Just like testing the webhook locally, this time I decided to use a local MongoDB server for the sake of local testing and development as I mentioned earlier. It's quite easy to spin up a MongoDB server locally and access it through localhost with Docker.

You could just use a regular docker command, but I prefer to use the docker-compose command because I feel it is a cleaner way to do it, and it's scalable in case you want to add more services in the future (e.g., Redis). To top it of, You can configure it in a file and commit it as a git commit. Here is the MongoDB docker-compose configuration:

YML
version: '3.8'
services:
mongo:
image: mongo
ports:
- "27017:27017"
volumes:
- ./mongo:/data/db

With this, you can start the MongoDB server locally by running the command:

Bash
docker-compose up

Once the server is up and running, you can access the database server locally at mongodb://localhost:27017/. You can use the Database tool window from IntelliJ IDE to explore or play around with your database, or you can use the MongoDB command line tool as well.

If you look at the Docker configuration, I specified one of the volumes entries as ./mongo:/data/db. This allows the database content to be persisted across container restarts by storing them in a mongo folder. I ensure that this mongo folder is not committed to git by adding it as one of the .gitignore entries because the database is for local testing purposes only.

Deploying to Railway

Deploying to Railway is quite straightforward. Here is an overview of how the deployment looks:

  1. Connect Railway to my GitHub account.
  2. Create a new project on Railway.
  3. On the project dashboard, create a new deployment and choose my GitHub project from the list.
  4. Adjust the environment variables and make the domain public.

I also needed to do a one-time database deployment on Railway. It's quite straightforward with Railway:

  1. On the project dashboard, click Create.
  2. Choose Database -> Add MongoDB.
  3. And you're done.

I also needed to set up the environment variables for the web project after that. For a more detailed tutorial, you can check out their documentation page.

Final Say

So far it's been interesting to develop and deploy my own project to a real server and learning new stuff.

Me and my partner sometime use this bot to start a conversation on Telegram and the bot usually come up with a funny answer which spark up the conversation. The other bot for image generation has been shutdown tho.