Refactoring a Node.js Telegram Bot - Part 1

In the previous post, I introduced you to Pop my Telegram chatbot, and I told you its story thus far. This one is more technical.

In the previous post, I introduced you to Pop my Telegram chatbot, and I told you its story thus far. This one is more technical. First, I  present the existing codebase and comment on the implementation. Then, following my remarks, I restructure the current project and refactor most of it. For all the files I refactor, I present the before and after so that it is easy to follow. However, this creates the illusion that the post is quite lengthy; don't be scared.
Finally, for your help here are the before and after versions of the repository:

The existing project

The project consists of two parts. The express server implemented in  index.js and the different modules in the app folder implement integrations with third parties.
The express server is relatively straightforward. It exposes two endpoints, one to check if the server is up and one that is responsible for receiving the chat messages from Telegram.
One thing I don't like is that all the server bootstrapping, controllers, services, middlewares are in one single file. In the specific case, it's not that big of a problem, but if we add more endpoints and more complicated business logic, this structure is not suitable at all.

// index.js
'use strict';
require('dotenv').config();

const telegramInboundEndpoint = 'https://popomastoras.herokuapp.com/';

const express = require('express');
const bodyParser = require('body-parser');

const app = express();
// to support JSON-encoded bodies and urlencoded bodies
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({
  extended: true, type: 'application/x-www-form-urlencoded' }));

const telegram = require('./app/telegram');
const utils = require('./app/utils');
const reactions = require('./app/reactions');
const watson = require('./app/watson');
watson.initChat();

// setup updates webhook
telegram.setupWebhook(telegramInboundEndpoint);

// Add CORS support
app.use(function (req, res, next){
  // for simple CORS requests we only need to allow the origin of the client
  res.header('Access-Control-Allow-Origin', '*');
  // for complex CORS requests we need to allow the HTTP methods and the HTTP
  // headers the client wants to use and also handle the preflight request
  // with app.options()
  res.header('Access-Control-Allow-Methods', 'GET, POST');
  // to allow JSON Content-Type
  res.header('Access-Control-Allow-Headers', 'Origin, Content-Type, Accept');
  next();
});

// Handler functions
function handleUpdates(req,res){
  //console.log(req.body);

  const user = utils.filterUser(req.body);
  const text = utils.filterText(req.body);
  const chatId = utils.filterChatId(req.body);
  const msgObject = utils.filterMsgId(req.body);

  // Watson Payload for the specific telegram message
  // Send payload the response has the action
  watson.sendMessage(text)
    .then(resp => {
      return reactions.reactToUserMessage(
        chatId, user,resp.output.text, resp.output.action, msgObject)
    })
    .then(() => res.status(200).send('OK'))
    .catch(err => console.log(err));
}

function welcome(req,res){
  res.status(200).send('Pop is up');
}

// Routes
app.post('/', handleUpdates);
app.get('/', welcome);

// Run server
const port = process.env.PORT || 3000;
app.listen(port, function(){
  console.log('Zagby bot server started on: ' + port);
});

As already mentioned above, the app folder contains modules responsible for the different integrations. For the most part, they are alright; the only thing I would do differently is treat them as libraries and change the way they are configured and initialized; this way, the application using them has control over these elements. Also, I would completely decouple the libraries. For example, you can see below that the OpenWeather library depends on the Telegram library to send a Telegram message. This part should happen in the business logic of the app and not inside the OpenWeather library.

// app/openweather.js

'use strict';

// openweather api token
const weatherToken = process.env.OPENWEATHER_API_TOKEN;
const fetch = require('node-fetch');
const telegram = require('./telegram');

function sendWeather(chatId, city, country){
  return fetch(`http://api.openweathermap.org/data/2.5/weather?q=${city},${country}&appid=${weatherToken}&lang=en&units=metric`)
    .then(res => res.json())
    .then(res => {
      //console.log(res);  // for debugging
      const weatherMsg = `<strong>${res.name}: </strong> ${res.weather[0].description}
      <strong>Temperature: </strong>${res.main.temp}°C,  
      <strong>Humidity: </strong>${res.main.humidity}%,
      <strong>Winds: </strong>${res.wind.speed} Bft`;

      telegram.sendMessageHTML(chatId, weatherMsg);
    })
    .catch(err => console.log(err));
}

module.exports = {
  sendWeather: sendWeather
};

Another thing living in the app folder that I did not mention earlier is the app/reactions.js file that is responsible for the way the bot replies. It is the central business logic of the application. To make this scalable, we need to keep the trigger keywords and the reactions in a data store instead of having them hardcoded in the file.

// app/reactions.js

'use strict';

const imgur = require('./imgur');
const telegram = require('./telegram');
const weather = require('./openweather');
const news = require('./newsapi');
const utils = require('./utils');


// ---------------- CUSTOM FUNCTIONS
function sendLands(chatId){
  return imgur.getRandomLandscape()
    .then(imgUrl => telegram.sendPicture(chatId, imgUrl))
    .catch(err => console.log(err));
}

module.exports = {
  reactToUserMessage: function(chatId, user, botMessage, text, msgId){
    // REACT ONLY WITH TEXT
    text = utils.standardizeString(text);
    if(text === 'pop'){
      if(user === 'dimkirt'){
        return telegram.sendMessage(chatId, 'Ναι μπαμπά;');
      }
      return telegram.sendMessage(chatId, 'NETI');
    }

    else if(text === 'end_conversation'){
      return telegram.sendMessage(chatId, 'Ante Geiaaaaaaaaaa!');
    }

    // Reply
    else if(text.includes('popira')){
      return telegram.replyWithMessage(chatId, 'parakalw', msgId);
    }

    // REACT ONLY WITH PICTURE
    else if(text.includes('dab')){
      return telegram.sendPicture(chatId, 'https://imgur.com/bsiD2P7');
    }

    else if(text.includes('sad')){
      const caption = 'SAD BOYZ';
      return telegram.sendPictureCaptioned(chatId, 'https://imgur.com/LeyYNEa', caption);
    }

    // REACT ONLY WITH GIF
    else if(text.includes('meli')){
      return telegram.sendDocument(chatId, 'https://imgur.com/0K9r9n0.gif');
    }

    // REACT WITH TEXT AND PICTURE
    else if(text === 'pop koumpwneis?'){
      const p1 = telegram.sendMessage(chatId, 'Όχι μαν μου, μόνο φυτικά παίρνω');
      const p2 = telegram.sendPicture(chatId, 'https://imgur.com/ek277iv');
      return Promise.all([p1, p2]);
    }

    // REACT WITH TEXT AND GIF
    else if(text === 'pop eisai kala?'){
      const p1 = telegram.sendMessage(chatId, 'Όχι μαν μου');
      const p2 = telegram.sendDocument(chatId, 'https://imgur.com/FWlJnDD.gif');
      return Promise.all([p1, p2]);
    }

    // CUSTOM REACTIONS
    else if(text === 'news_google'){
      return news.sendNews(chatId, 'google-news');
    }

    else if(text === 'weather_thessaloniki'){
      return weather.sendWeather(chatId, 'Thessaloniki', 'gr');
    }

    else if(text === 'weather_munich'){
      return weather.sendWeather(chatId, 'Munich', 'de');
    }

    else if(text === 'images_nature'){
      return sendLands(chatId);
    }

    else if(text === 'playlist_hiphop'){
      return telegram.sendMessage(chatId, 'https://www.youtube.com/playlist?list=PLAFQAQCf660pJgBxh13wGlbK_aQDoFZLi');
    }

    else if(text === 'location_dps'){
      return telegram.sendLocation(chatId, 48.176464, 11.592553);
    }

    else{
      const promisesArr = botMessage.map(x => telegram.sendMessage(chatId, x));
      return promisesArr.reduce((accum, task) => {
        return Promise.all([accum, task])
          .then(res => [... res[0], res[1]]);
      }, Promise.resolve([]));
    }
  }
};

Finally, the deployment is also elementary since we have a single web application that we serve using Heroku so, we only need a one-line Procfile to create a deployment. In future posts, as we add more features and infrastructure, we will explore more complex deployment schemes.

Linting

Before we start refactoring, it would be wise to add linting. It's essential to use a linter to ensure code quality and consistency. For JavaScript, I use ESLint since it's widely adopted by the JS ecosystem.

Below you can find my configuration:

// .eslintrc.js
module.exports = {
  env: {
    commonjs: true,
    es6: true,
    node: true,
    jest: true,
    mocha: true,
  },
  extends: [
    'airbnb-base',
  ],
  globals: {
    Atomics: 'readonly',
    SharedArrayBuffer: 'readonly',
  },
  parserOptions: {
    ecmaVersion: 2018,
  },
  rules: {
    'quotes': [2, 'single'],
    'indent': [2, 2],
    'max-len': [2, 120],
    // We disable this to make life with Mongo easier
    'no-underscore-dangle': 0,
    'require-await': 1,
    'prefer-promise-reject-errors': 1,
    'no-magic-numbers': 1
  },
};

I'm using the Airbnb styleguide as a base.

We need the following dev dependencies:

npm i --save-dev eslint eslint-config-airbnb-base eslint-plugin-import

And to make it work in VSCode, we install the vscode-eslint extension.
Then create .vscode/settings.json in your project with the following settings:

{
  "eslint.enable": true,
  "eslint.alwaysShowStatus": true,
  "eslint.nodePath": "./node_modules/.bin/",
  "eslint.options": {
    "configFile": ".eslintrc.js"
  },
  "[javascript]": {
    "editor.tabSize": 2,
    "editor.codeActionsOnSave": { "source.fixAll.eslint": true }
  }
}

Restart your editor, and now ESLint should be working.

Refactoring Time

Let's start by splitting app/ folder into, app/ and libs/ then inside libs/ I create a directory for each integration. Thus, ending up with the following structure:

src/app/utils.js
src/app/reactions.js
src/libs/imgur/index.js
src/libs/news-api/index.js
src/libs/open-weather-map/index.js
src/libs/telegram/index.js
src/index.js

We remove app/watson.js since we won't use it anymore.
I start the refactoring using libs/open-weather-map/index.js as an example. I already showed you the initial version; it is the one below:

'use strict';

// openweather api token
const weatherToken = process.env.OPENWEATHER_API_TOKEN;
const fetch = require('node-fetch');
const telegram = require('../telegram');

function sendWeather(chatId, city, country){
  return fetch(`http://api.openweathermap.org/data/2.5/weather?q=${city},${country}&appid=${weatherToken}&lang=en&units=metric`)
    .then(res => res.json())
    .then(res => {
      //console.log(res);  // for debugging
      const weatherMsg = `<strong>${res.name}: </strong> ${res.weather[0].description}
      <strong>Temperature: </strong>${res.main.temp}°C,  
      <strong>Humidity: </strong>${res.main.humidity}%,
      <strong>Winds: </strong>${res.wind.speed} Bft`;

      telegram.sendMessageHTML(chatId, weatherMsg);
    })
    .catch(err => console.log(err));
}

module.exports = {
  sendWeather: sendWeather
};

As mentioned before, the main problem with this library is the dependency on Telegram. To be more accurate, the dependency per se is not the problem but an indication that this library captures the wrong abstraction. Let me elaborate; following the Single-responsibility principle, the purpose of this library should be only to get the weather data from the third party. Formatting the data and then sending them to Telegram should not be the responsibility of the API wrapper. Instead, it is the responsibility of the consumer of the weather data to implement these operations. So that part of the logic will be moved in the service that is responsible for the different reactions.

Moreover, I do the following minor changes:

  • Create a class to wrap the library logic and create a dedicated namespace.
  • Change to Axios lib for HTTP calls since it offers more capabilities than node-fetch.
  • Refactor promises to async/await syntax to improve readability.

Below is the refactored version:

const axios = require('axios');

function OpenWeatherError(error) {
  if (error.isAxiosError) {
    throw new Error(error.response.data.message);
  }
}

class OpenWeatherApi {
  constructor({ apiToken, language, unitSystem = 'metric' }) {
    this.apiToken = apiToken;
    this.language = language;
    this.unitSystem = unitSystem;
    this.client = axios.create({
      baseURL: 'http://api.openweathermap.org/data/2.5/',
      timeout: 2000,
    });
  }

  /**
    * Get weather data for a specific city
    *
    * @param {string} city The name of the city in English e.g. "Thessaloniki"
    * @param {string} country The ISO 3166 country code in lowercase e.g. "gr"
    */
  async getWeatherByCity(city, country) {
    const url = `weather?q=${city},${country}&appid=${this.apiToken}&lang=${this.language}&units=${this.unitSystem}`;
    const res = await this.client.get(url).catch(OpenWeatherError);
    return res.data;
  }
}

module.exports = {
  OpenWeatherApi,
};

We follow the same methodology for libs/news-api/index.js and libs/imgur/index.js since they have common pitfalls. Thus I won't present them in detail. However, the Telegram library is more interesting. Below is the original file:

'use strict';

// api token, base url construction
const token = process.env.TELEGRAM_BOT_API_TOKEN;
const apiUrl = `https://api.telegram.org/bot${token}/`;

const fetch = require('node-fetch');
const FormData = require('form-data');

// implementation of telegram wrappers
function sendMessage(baseUrl, chatId, msg){
  const form = new FormData();
  form.append('chat_id', chatId);
  form.append('text', msg);

  return fetch(baseUrl+'sendMessage', {
    method: 'POST',
    body: form
  })
    .then(res => res.json())
    .catch(err => {throw new Error('Error caught in sendMessage ' + err.message);});
}

function sendMessageMarkdown(baseUrl, chatId, msg){
  const form = new FormData();
  form.append('chat_id', chatId);
  form.append('text', msg);
  form.append('parse_mode', 'Markdown');

  return fetch(baseUrl+'sendMessage', {
    method: 'POST',
    body: form
  })
    .then(res => res.json())
    .catch(err => {throw new Error('Error caught in sendMessage ' + err.message);});
}

function sendMessageHTML(baseUrl, chatId, msg){
  const form = new FormData();
  form.append('chat_id', chatId);
  form.append('text', msg);
  form.append('parse_mode', 'HTML');

  return fetch(baseUrl+'sendMessage', {
    method: 'POST',
    body: form
  })
    .then(res => res.json())
    .catch(err => {throw new Error('Error caught in sendMessage ' + err.message);});
}

function sendPicture(baseUrl, chatId, photoUrl){
  const form = new FormData();
  form.append('chat_id', chatId);
  form.append('photo', photoUrl);

  return fetch(baseUrl+'sendPhoto', {
    method: 'POST',
    body: form
  })
    .then(res => res.json())
    .catch(err => console.log(err));
}

function sendPictureCaptioned(baseUrl, chatId, photoUrl, caption){
  const form = new FormData();
  form.append('chat_id', chatId);
  form.append('photo', photoUrl);
  form.append('caption', caption);

  return fetch(baseUrl+'sendPhoto', {
    method: 'POST',
    body: form
  })
    .then(res => res.json())
    .catch(err => {throw new Error('Error caught in sendPicture ' + err.message);});
}

function sendDocument(baseUrl, chatId, documentUrl){
  const form = new FormData();
  form.append('chat_id', chatId);
  form.append('document', documentUrl);

  return fetch(baseUrl+'sendDocument', {
    method: 'POST',
    body: form
  })
    .then(res => res.json())
    .catch(err => {throw new Error('Error caught in sendDocument ' + err.message);});
}

function sendLocation(baseUrl, chatId, latitude, longitude){
  const form = new FormData();
  form.append('chat_id', chatId);
  form.append('latitude', latitude);
  form.append('longitude', longitude);

  return fetch(baseUrl+'sendLocation', {
    method: 'POST',
    body: form
  })
    .then(res => res.json())
    .catch(err => {throw new Error('Error caught in sendLocation ' + err.message);});
}

function replyWithMessage(baseUrl, chatId, msg, msgId){
  const form = new FormData();
  form.append('chat_id', chatId);
  form.append('text', msg);
  form.append('reply_to_message_id', msgId);

  return fetch(baseUrl+'sendMessage', {
    method: 'POST',
    body: form
  })
    .then(res => res.json())
    .catch(err => {throw new Error('Error caught in sendMessage ' + err.message);});
}

function getMe(baseUrl){
  return fetch(baseUrl+'getMe', {method: 'GET'})
    .then(res => res.json())
    .catch(err => {throw new Error('Error caught in getMe ' + err.message);});
}

function setupWebhook(baseUrl, url){
  const form = new FormData();
  form.append('url', url);

  return fetch(baseUrl+'setWebhook', {
    method: 'POST',
    body: form
  })
    .then(res => res.json())
    .catch(err => {throw new Error('Error caught in setWebhook ' + err.message);});
}

module.exports = {
  getMe: function(){
    return getMe(apiUrl);
  },

  setupWebhook: function(url){
    return setupWebhook(apiUrl, url);
  },

  sendMessage: function(chatId, msg){
    return sendMessage(apiUrl, chatId, msg);
  },

  sendMessageMarkdown: function(chatId, msg){
    return sendMessageMarkdown(apiUrl, chatId, msg);
  },

  sendMessageHTML: function(chatId, msg){
    return sendMessageHTML(apiUrl, chatId, msg);
  },

  sendPicture: function(chatId, photoUrl){
    return sendPicture(apiUrl, chatId, photoUrl);
  },

  sendPictureCaptioned: function(chatId, photoUrl, caption){
    return sendPictureCaptioned(apiUrl, chatId, photoUrl, caption);
  },

  sendDocument: function(chatId, documentUrl){
    return sendDocument(apiUrl, chatId, documentUrl);
  },

  sendLocation: function(chatId, latitude, longitude){
    return sendLocation(apiUrl, chatId, latitude, longitude);
  },

  replyWithMessage: function(chatId, msg, msgId){
    return replyWithMessage(apiUrl, chatId, msg, msgId);
  }
};

The file is a bit long, but it should not be difficult to understand. What do you think is the problem in this case? If you guessed duplication, you are right. For example, sendPicture and sendPictureCaptioned should be one function with different options, same for sendMessage, sendMessageMarkdown and sendMessageHTML. Moreover, replyWithMessage should not be a function but an option in all other functions since you can reply with a text, a picture, a document, or a location. All these problems originate from the same problem we mentioned earlier, which is that this library is trying to do too many things instead of being a thin wrapper around the third-party API. Let's fix this!

To stay consistent with the other libraries we:

  • Encapsulate everything in a class.
  • Use Axios instead of node-fetch.
  • Use async/await syntax.

This is the end result after refactoring:

const axios = require('axios');

function TelegramError(error) {
  if (error.isAxiosError) {
    throw new Error(error.response.data.description);
  }
}

class TelegramBotApi {
  constructor({ apiToken }) {
    this.apiToken = apiToken;
    this.client = axios.create({
      baseURL: `https://api.telegram.org/bot${this.apiToken}/`,
    });
  }

  /**
   * Set the webhook for receiving messages from Telegram
   *
   * @param {string} callbackUrl The endpoint that Telegram will push messages to
   * @param {object} options Optional arguments https://core.telegram.org/bots/api#setwebhook
   */
  async setWebhook(callbackUrl, options) {
    const body = {
      url: callbackUrl,
      ...options,
    };

    const res = await this.client.post('setWebhook', body).catch(TelegramError);
    return { success: res.data.result, message: res.data.description };
  }

  /**
   * Get bot metadata
   */
  async getMe() {
    const res = await this.client.get('getMe').catch(TelegramError);
    return res.data.result;
  }

  /**
   * Send a text message to the destination chat
   *
   * @param {string} chatId Destination chat
   * @param {string} text Text message
   * @param {object} options Optional arguments https://core.telegram.org/bots/api#sendmessage
   */
  async sendMessage(chatId, text, options) {
    const body = {
      chat_id: chatId,
      text,
      ...options,
    };

    const res = await this.client.post('sendMessage', body).catch(TelegramError);
    return res.data.result;
  }

  /**
   * Send a photo message to the destination chat
   *
   * @param {string} chatId Destination chat
   * @param {string} photoUrl Photo resource
   * @param {object} options Optional arguments https://core.telegram.org/bots/api#sendphoto
   */
  async sendPhoto(chatId, photoUrl, options) {
    const body = {
      chat_id: chatId,
      photo: photoUrl,
      ...options,
    };

    const res = await this.client.post('sendPhoto', body).catch(TelegramError);
    return res.data.result;
  }

  /**
   * Send a document message to the destination chat
   *
   * @param {string} chatId Destination chat
   * @param {string} documentUrl Document resource
   * @param {object} options Optional arguments https://core.telegram.org/bots/api#senddocument
   */
  async sendDocument(chatId, documentUrl, options) {
    const body = {
      chat_id: chatId,
      document: documentUrl,
      ...options,
    };

    const res = await this.client.post('sendDocument', body).catch(TelegramError);
    return res.data.result;
  }

  /**
   * Send a location message to the destination chat
   *
   * @param {string} chatId Destination chat
   * @param {object} location Location object { latitude: 0.0, longitude: 0.0 }
   * @param {object} options Optional arguments https://core.telegram.org/bots/api#sendlocation
   */
  async sendLocation(chatId, location, options) {
    const body = {
      chat_id: chatId,
      ...location,
      ...options,
    };

    const res = await this.client.post('sendLocation', body).catch(TelegramError);
    return res.data.result;
  }
}

module.exports = {
  TelegramBotApi,
};

As you can see, I remove all function duplication and don't explicitly set options in this library, since this abstraction level belongs to the business logic of the component consuming this library.

Summary

So that is for today, to sum it up, you first had a short introduction to the project. Then we set up ESLint and refactored the first significant part of the project, the libraries encapsulating third-party integrations. In the following post, we will refactor the second central part of it, which is the web app. We will completely redesign the reaction implementation logic and structure the application in a way that allows us to extend the project efficiently.

I hope you found this post informative! Stay tuned!

Subscribe to Backend Definite

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe