homeresume
 
   

Telegram bots with Node.js

November 7, 20225 min read

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

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...');
}
})();

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,
};

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,
};

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"
}
// ...
}

Linter

Update ecmaVersion field in eslint config to 2021.

Logger

const logger = require('pino')();
module.exports = {
logger,
};

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)]);
};

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,
};

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
fly secrets set TELEGRAM_ACCESS_TOKEN=<ACCESS_TOKEN>
npm run telegram:webhook-set https://<PROJECT_NAME>.fly.dev/webhooks/telegram

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.

 

© 2022