DEV Community

Željko Šević
Željko Šević

Posted on • Originally published at sevic.dev on

Telegram bots with Node.js

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.

npx create-bottender-app <bot-name>
cd <bot-name>
npm i express body-parser cron ngrok shelljs pino
npm i nodemon -D
Enter fullscreen mode Exit fullscreen mode

Publicly exposed URL

Telegram bots require publicly available URLs for the webhooks. Ngrok 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.

// 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...');
  }
})();
Enter fullscreen mode Exit fullscreen mode

Custom server implementation for the webhook

// 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,
};
Enter fullscreen mode Exit fullscreen mode

Custom scheduler

// 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,
};
Enter fullscreen mode Exit fullscreen mode

Npm scripts

// 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"
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Linter

Update ecmaVersion field in eslint config to 2021.

Logger

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

module.exports = {
  logger,
};
Enter fullscreen mode Exit fullscreen mode

Bot development

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

// 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)]);
};
Enter fullscreen mode Exit fullscreen mode

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.

// 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,
};
Enter fullscreen mode Exit fullscreen mode

Error handling

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

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.'
  );
};
Enter fullscreen mode Exit fullscreen mode

Deployment

One of the options to deploy a Telegram bot is fly.io running the following commands for the setup and deployment.

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
Enter fullscreen mode Exit fullscreen mode

More details about the deployment are covered in Deploying Node.js apps to Fly.io post.

Boilerplate

Here is the link to the boilerplate I use for the development.

Top comments (0)