Telegram bots with Node.js
Tags: node, bottender, flyio, telegram
Published: 2022-11-07 11:26:20

---
title: Telegram bots with Node.js
published: true
date: 2022-11-07 11:26:00 UTC
tags: ['node', 'bottender', 'flyio', 'telegram']
canonical_url: https://sevic.dev/telegram-bots-nodejs/
---

Telegram bots, besides regular chatbots, can also be used as "dashboards". Authentication is already there, as well as the UI to send commands to receive specific data. One additional option is to implement a cronjob that executes some commands and sends a response to the user. Also setting up the bot is much faster compared to other platforms.

### Prerequisites

- Telegram app installed

### Telegram setup

Find `BotFather` account and send `/newbot` command. Choose name and username, after that you'll get an access token for a newly created bot. To add default commands for it, send `/setcommands` command and send commands as it's stated in the instructions.

### Setup for development environment

#### Bootstrapping bot

Bottender is a great framework for developing bots, it supports multiple platforms. Start with the following command and choose platform and session store. Update `.env` file with the access token that `BotFather` sent.

```bash
npx create-bottender-app <bot-name>
cd <bot-name>
npm i express body-parser cron ngrok shelljs pino
npm i nodemon -D
```

#### Publicly exposed URL

Telegram bots require publicly available URLs for the webhooks. [Ngrok](https://ngrok.com/) creates a secure public URL pointing to the local server while Bottender enables webhook integrations. To automate this process, a custom server has to be implemented.

#### Server setup

Bellow implementation starts the cronjob and custom server with an automated connection to the tunnel and webhook URL setup.

```js
// server.js
const { bottender } = require('bottender');
const ngrok = require('ngrok');
const shell = require('shelljs');
const { setupCustomServer } = require('./src/custom-server');
const { logger } = require('./src/logger');
const { setupScheduler } = require('./src/scheduler');

const app = bottender({
  dev: process.env.NODE_ENV !== 'production',
});

const setWebhookUrl = (url) =>
  shell.exec(`npm run telegram-webhook:set ${url}/webhooks/telegram`);

const connectToTunnel = async (port) => {
  const url = await ngrok.connect({
    addr: port,
    onStatusChange: (status) => {
      switch (status) {
        case 'connected': {
          logger.info('Connected to tunnel...');
          break;
        }
        case 'closed': {
          logger.warn('Connection to tunnel is closed...');
          logger.info('Reconnecting...');
          return connectToTunnel(port);
        }
      }
    },
  });
  setWebhookUrl(url);
};

(async () => {
  try {
    await app.prepare();
    const port = Number(process.env.PORT) || 5000;
    setupCustomServer(app, port);

    if (process.env.NODE_ENV !== 'production') {
      await connectToTunnel(port);
    }
    setupScheduler();
  } catch (error) {
    logger.error(error, 'Setting up failed...');
  }
})();
```

#### Custom server implementation for the webhook

```js
// src/custom-server.js
const bodyParser = require('body-parser');
const express = require('express');
const { logger } = require('./logger');

const setupCustomServer = (app, port) => {
  // the request handler of the bottender app
  const handle = app.getRequestHandler();

  const server = express();

  const verify = (req, _, buf) => {
    req.rawBody = buf.toString();
  };
  server.use(bodyParser.json({ verify }));
  server.use(bodyParser.urlencoded({ extended: false, verify }));

  // route for webhook request
  server.all('*', (req, res) => {
    return handle(req, res);
  });

  server.listen(port, (err) => {
    if (err) throw err;
    logger.info(`Ready on http://localhost:${port}`);
  });
};

module.exports = {
  setupCustomServer,
};
```

#### Custom scheduler

```js
// src/scheduler.js
const { getClient } = require('bottender');
const { CronJob } = require('cron');
const { CHAT_ID, CRONJOB_INTERVAL, replyMarkup, TIMEZONE } = require('./constants');
const { executeCustomCommand } = require('./services');

const client = getClient('telegram');

const setupScheduler = () =>
  new CronJob(
    CRONJOB_INTERVAL,
    async function () {
      const response = await executeCustomCommand();

      await client.sendMessage(CHAT_ID, response, {
        parseMode: 'HTML',
        replyMarkup,
      });
    },
    null,
    true,
    TIMEZONE,
  );

module.exports = {
  setupScheduler,
};
```

#### Npm scripts

```json
// package.json
{
  // ...
  "scripts": {
    "dev": "nodemon server.js",
    "lint": "eslint . ",
    "lint:fix": "npm run lint -- --fix",
    "start": "node server.js",
    "telegram-webhook:set": "echo 'Y' | bottender telegram webhook set -w $1",
    "test": "jest"
  }
  // ...
}
```

#### Linter

Update `ecmaVersion` field in eslint config to `2021`.

#### Logger

```js
const logger = require('pino')();

module.exports = {
  logger,
};
```

### Bot development

Below is the bot entry point, multiple handlers can be specified.

```js
// src/index.js
const { router, telegram } = require('bottender/router');
const { HandleMessage } = require('./handlers/message-handler');

module.exports = async function App() {
  return router([telegram.message(HandleMessage)]);
};
```

Bellow is basic message handler implementation. Restricting the access to the bot can be done by chat id. For a message with HTML content, `parseMode` parameter should be set to `HTML`. Quick replies can be added in `replyMarkup` field. Received bot commands have a type `bot_command`.

```js
// src/handlers/message-handler.js
const { ADMIN_CHAT_ID } = require('../constants');
const { handleCustomLogic } = require('../services');

async function HandleMessage(context) {
  const chatId = context.event._rawEvent.message?.chat?.id;
  if (chatId !== ADMIN_CHAT_ID) {
    await context.sendMessage('Access denied!');
    return;
  }

  const isBotCommand = !!context.event._rawEvent.message?.entities?.find(
    (entity) => entity.type === 'bot_command'
  );
  const message = isBotCommand
    ? context.event.text.replace('/', '')
    : context.event.text;

  const response = await handleCustomLogic(message);

  await context.sendMessage(response, {
    parseMode: 'HTML',
    replyMarkup: {
      keyboard: [
        [
          {
            text: '/command',
          },
        ],
      ],
    },
  });
}

module.exports = {
  HandleMessage,
};
```

### Error handling

Define the custom error handler in the `_error.js` file.

```js
const { logger } = require('./src/logger');

module.exports = async (context, props) => {
  logger.error(props.error);

  await context.sendMessage(
    'There are some unexpected errors that happened. Please try again later. Sorry for the inconvenience.'
  );
};
```

### Deployment

One of the options to deploy a Telegram bot is [fly.io](https://fly.io/) running the following commands for the setup and deployment.

```bash
curl -L https://fly.io/install.sh | sh
fly auth signup
fly launch
fly deploy --no-cache
fly secrets set TELEGRAM_ACCESS_TOKEN=<ACCESS_TOKEN>
npx bottender telegram webhook set -w https://<PROJECT_NAME>.fly.dev/webhooks/telegram
```

More details about the deployment are covered in [Deploying Node.js apps to Fly.io](https://sevic.dev/nodejs-deployment-flyio/) post.

### Boilerplate

Here is the [link](https://github.com/zsevic/chatbot-starter) to the boilerplate I use for the development.