Refactoring a Node.js Telegram Bot - Part 2

Today, we continue refactoring the chatbot project!

Hello everyone! In the previous post, we spent a lot of time refactoring our third-party integration libraries. Feel free to check it out if you missed it! Today, we continue refactoring the chatbot project, but this time we focus on the web application structure as well as the main logic of the bot that involves all the reactions to the user input. In the end, I present the new goals for this project and give you a small teaser about what's to follow.

Restructuring the web application

Before we start, in case you want to follow along and have the whole picture in place:

If you read the previous post, you might remember how the index.js looked like, nonetheless here it is. Quite messy!

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

Listing 1 - index.js

We split the index.js into two files. First, app.js is responsible for packaging all elements of the application and exposing a factory function. Also, main.js is creating the application object, bootstrapping the Telegram webhook, and starting the server.

You would think that main.js should be straightforward, right? Which is true, so let's start there.

const { TelegramBotApi } = require('./src/libs/telegram');
const { createApp } = require('./src/app/app.js');

const DEFAULT_PORT = 3000;

function createTelegramWebhook() {
  const telegramBotApi = new TelegramBotApi({
    apiToken: process.env.TELEGRAM_BOT_API_TOKEN,
  });
  return telegramBotApi.setWebhook(process.env.TELEGRAM_WEBHOOK_URL);
}

async function main() {
  await createTelegramWebhook();
  const app = createApp();

  app.listen(process.env.PORT || DEFAULT_PORT, () => {
    console.log(`Server started on: ${process.env.PORT || DEFAULT_PORT}`);
  });
}

main();

Listing 2 - main.js

The main.js file is responsible for three things:

  • First, it creates the Telegram webhook, this call is idempotent, so in case the webhook already exists, it won't have any effect; if it doesn't exist, it will create the webhook. We keep the bot token in an environmental variable so we can have different bots for the same codebase, and because we should not expose this information in our code. The same applies to the webhook URL since it depends on where we deploy our bot.
  • Second, it imports the application factory function and creates an application object. In this place, we could also set different environmental variables or pass a configuration in the application factory.
  • Finally, it starts the express server.

Now let's move on to the app.js, which is also simple, but we have some things to discuss.

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

const reactions = require('./reactions');

function cors(req, res, next) {
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Methods', 'GET, POST');
  res.header('Access-Control-Allow-Headers', 'Origin, Content-Type, Accept');
  next();
}

const asyncErrorHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);

function createApp() {
  const app = express();
  app.use(bodyParser.json());
  app.use(cors);

  app.post('/messages', asyncErrorHandler(async (req, res) => {
    const message = req.body.message ? req.body.message : req.body.edited_message;
    await reactions.reactToUserMessage(message);
    return res.json({ success: true });
  }));

  app.get('/health', (req, res) => res.json({ up: true }));

  return app;
}

module.exports = {
  createApp,
};

Listing 3 - app/app.js

We still have all the Express application logic in one file, routes, controllers, middleware, and bootstrapping. Currently, I think this is ok since the web app is trivial. However, I will change it in the upcoming posts when I add more functionality and new endpoints. Usually, I would split the logic into modules where each module has its middleware, controller, and service files; or if we have a smaller application, we could go for a structure where we have separate folders for controllers, middleware, services. However, I find the second approach very confusing especially, when a project starts to grow because you cannot differentiate the logic of different modules easily.

One more thing I want to bring up is the separation of the bot's core business logic and the Express server, which is the way we expose the bot logic. However, we could use a different API to the bot logic, for example, a CLI. So in the next step, I remove the reaction.js from the app/ folder, and I rename the app/ folder to rest-api/. Moreover, I create a new bot-core folder where I put all the core logic of the bot, which is only the reactions.js file, for now. So the new project structure looks like this:

src/bot-core/reactions.js
src/rest-api/app.js
src/libs/*
main.js
Listing 4 - New project structure

Restructuring the bot core

Currently, all the core logic of the bot lies in the reactions.js file. The logic is very primitive. It's the function reactToUserMessage that takes as input the user text and metadata and then reacts to this input. You can find the file below.

'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([]));

      //return telegram.sendMessage(chatId, botMessage[0]);
    }
  }
};

Listing 5 - old app/reactions.js

What a monstrosity, right? Just a bunch of if statements, and that's it—a ton of duplication and not scalable at all. Imagine having a thousand reactions. You might also have noticed, in the first if condition, where I do an extra check for the user name. Imagine the mess we will have in our hands with keywords triggering different reactions for different users or different chats. We would have a ton of nested ifs on top of this. So how do we fix this mess?

First of all, we need to approach the problem from the definition of a reaction. What is a reaction, and how could we model it? My first approach to model it looked like this:

{
  id: 0,
  keyword: 'pop',
  match: 'EXACT',
  users: [],
  chats: [],
  action: {
    type: 'sendMessage',
    payload: {
      message: {
        text: 'NETI',
        reply: true,
      },
    },
  },
},
Listing 6 - Reaction rough data model

We have a keyword that is what we are looking for in the user's text; then, we have a match property to define if we want the incoming message to match precisely the keyword, or we just want to include it. Next, we have the action property that should be a function name we want to execute along with the arguments to pass in the payload sub-property. Finally, I added a users and chats arrays to be able to allow some reactions only for specific users and chats.

With this in mind, I do the following refactoring in the reactions.js file; I also rename the file to bot.js:

const greekUtils = require('greek-utils');

class Bot {
  constructor({
    telegramBotApi,
    openWeatherApi,
    newsApi,
    imgurApi,
    reactionsRepository,
  }) {
    this.telegramBotApi = telegramBotApi;
    this.openWeatherApi = openWeatherApi;
    this.newsApi = newsApi;
    this.imgurApi = imgurApi;
    this.reactionsRepository = reactionsRepository;

    this.functionFactory = {
      sendMessage: this.sendMessage,
      sendPhoto: this.sendPhoto,
      sendDocument: this.sendDocument,
      sendLocation: this.sendLocation,
      sendMessageWithWeatherForCity: this.sendMessageWithWeatherForCity,
    };
  }

  executeReaction(reaction, chatId, msgId) {
    const isValidAction = action.type in this.functionFactory;
    if (isValidReaction) {
      const payload = { ...reaction.action.payload, chatId, msgId };
      return this.functionFactory[reaction.action.type].call(this, payload);
    }
    return Promise.resolve();
  }

  sendMessage(payload) {
    const options = {};
    if (payload.message.reply) {
      options.reply_to_message_id = payload.msgId;
    }
    return this.telegramBotApi.sendMessage(payload.chatId, payload.message.text, options);
  }

  sendPhoto(payload) {
    const options = {};
    if (payload.photo.caption) {
      options.caption = payload.photo.caption;
    }
    return this.telegramBotApi.sendPhoto(payload.chatId, payload.photo.photoUrl, options);
  }

  sendDocument(payload) {
    const options = {};
    if (payload.document.caption) {
      options.caption = payload.document.caption;
    }
    return this.telegramBotApi.sendDocument(payload.chatId, payload.document.documentUrl, options);
  }

  sendLocation(payload) {
    const options = {};
    if (payload.location.caption) {
      options.caption = payload.location.caption;
    }
    return this.telegramBotApi.sendLocation(payload.chatId, payload.location, options);
  }

  async sendMessageWithWeatherForCity(payload) {
    const weatherData = await this.openWeatherApi.getWeatherByCity(payload.city, payload.country);
    const text = `<strong>${weatherData.name}: </strong> ${weatherData.weather[0].description}
      <strong>Temperature: </strong>${weatherData.main.temp}°C,  
      <strong>Humidity: </strong>${weatherData.main.humidity}%,
      <strong>Winds: </strong>${weatherData.wind.speed} Bft`;

    const options = {
      parse_mode: 'HTML',
    };

    return this.telegramBotApi.sendMessage(payload.chatId, text, options);
  }

  async sendMessageArticlesForNewsSource(payload) {
    function sendNewsArticleToTelegram(article) {
      const options = {
        parse_mode: 'HTML',
      };
      const text = `<b>${article.title}:</b> ${article.url}`;
      return this.telegramBotApi.sendMessage(payload.chatId, text, options);
    }

    const articles = await this.newsApi.getArticlesBySource(payload.source);
    return Promise.all(articles.map(sendNewsArticleToTelegram.bind(this)));
  }

  reactToUserMessage(message) {
    const { text } = message;
    const chatId = message.chat.id;
    const user = message.from.username;
    const msgId = message.message_id;

    const standardizedText = greekUtils.toGreeklish(text).toLowerCase();
    const reaction = this.reactionsRepository.findOne({
      standardizedText, chatId, user,
    });

    if (!reaction) {
      return Promise.resolve();
    }
    return this.executeReaction(reaction, chatId, msgId);
  }
}

module.exports = {
  Bot,
};

Listing 5 - bot-core/bot.js

If you read my previous post, you might remember that I removed a lot of logic from the libraries and said that I would move them in the core logic of the bot. You can see this now; all the formulation of data we get from third parties and then sending the data through a Telegram message happens in dedicated functions. I omitted the Imgur-related functionality to keep it short. Finally, I wrapped all the core bot logic in a Bot class.

Moreover, I created a factory function that setups the bot in bot-core/index:

const { TelegramBotApi } = require('../libs/telegram');
const { OpenWeatherApi } = require('../libs/open-weather-map');
const { NewsApi } = require('../libs/news-api');
const { ImgurApi } = require('../libs/imgur');

const { Bot } = require('./reactions');
const reactionsRepository = require('./data');

function createBot() {
  const telegramBotApi = new TelegramBotApi({
    apiToken: process.env.TELEGRAM_BOT_API_TOKEN,
  });

  const openWeatherApi = new OpenWeatherApi({
    apiToken: process.env.OPENWEATHER_API_TOKEN,
    language: 'en',
  });

  const newsApi = new NewsApi({
    apiToken: process.env.NEWS_API_TOKEN,
  });

  const imgurApi = new ImgurApi({
    apiClientId: process.env.IMGUR_CLIENT_ID,
    apiClientSecret: process.env.IMGUR_CLIENT_SECRET,
  });

  return new Bot({
    telegramBotApi, openWeatherApi, newsApi, imgurApi, reactionsRepository,
  });
}

module.exports = {
  createBot,
};

Listing 6 - bot-core/index.js

The web application will use this function to bootstrap the bot without the need to care about what to inject in the Bot constructor.

Refinement

Even though things look much better now, I spot one more problem. The reactionsRepository in the reactToUserMessage function is supposed to be able to find a reaction by the provided user input text. Such complex logic should not happen in the repository of an entity. Thinking about it a bit more, I conclude that the Reaction is trying to model too many things. We try to model the intention of the user's incoming text and, on top of that, the action that the bot should take in response to this input.
Moreover, what happens if the bot starts being proactive? For example, imagine we have a task scheduler, and then the bot acts on its own will and not as a response to user input. In this case, we have to remodel the actions the bot takes in a different context. Then the two models will overlap; this is bad. So I think it is better if we split the data model into two entities the Action, which models the possible actions the bot can take, and the Intension which models the intension of the user. Then we derive the intention from the user input, and we map each Intention to a specific Action; these models look like this:

{
  id: '5e921abb8db116d84ba09c79',
  type: 'sendMessage',
  payload: {
    message: {
      text: 'NETI',
      reply: true,
    },
  },
}
Listing 7 - Action model
{
  id: '5e921aea1806ebd59a2896ac',
  keyword: 'pop',
  match: 'EXACT',
  users: [
    {
      username: 'dimkirt',
      action: '5e921b83dedbf4473708acde',
    },
  ],
  chats: [],
  action: '5e921abb8db116d84ba09c79',
},
Listing 8 - Intention model

So first of all, we rename reactions to actions. Then we need to create an IntentionService that we inject in the bot. This service encapsulates all the logic that is related to deciding what the intention of the user is and what is the respective action the bot will take.
At this point, I also think that it might be a good idea to encapsulate the actions logic in one service. But one step at a time. Below is the new IntentionService, and the mock IntentionRepository. I also created an index.js file in the intentions module that exposes a factory function. This way the consumer of this module does not need to inject all dependencies.

const greekUtils = require('greek-utils');

class IntentionService {
  constructor({ intentionRepository }) {
    this.intentionRepository = intentionRepository;
  }

  async determineIntention({ text }) {
    const standardizedText = greekUtils.toGreeklish(text).toLowerCase();
    const intentions = await this.intentionRepository.find();
    const isExactMatch = (intention) => intention.match === 'EXACT' && intention.keyword === standardizedText;
    const isIncludeMatch = (intention) => intention.match === 'INCLUDE' && standardizedText.includes(intention.keyword);

    const matchingIntentions = intentions.filter((intention) => isExactMatch(intention) || isIncludeMatch(intention));
    if (matchingIntentions.length) {
      return matchingIntentions[0];
    }
    throw new Error('No matching Intention');
  }

  determineAction({ intention, chatId, userId }) {
    // User specific action has priority over chat specific action
    const matchingUsers = intention.users.filter((user) => user.username === userId);
    if (matchingUsers.length) {
      return matchingUsers[0].action;
    }

    const matchingChats = intention.chats.filter((chat) => chat.id === chatId);
    if (matchingChats.length) {
      return matchingChats[0].action;
    }

    return intention.action;
  }
}

module.exports = {
  IntentionService,
};

Listing 9 - bot-core/intentions/intention-service.js
class IntentionRepository {
  constructor() {
    this.intentions = [
      {
        id: '5e921aea1806ebd59a2896ac',
        keyword: 'pop',
        match: 'EXACT',
        users: [
          {
            username: 'dimkirt',
            action: '5e921b83dedbf4473708acde',
          },
        ],
        chats: [],
        action: '5e921abb8db116d84ba09c79',
      },
      {
        keyword: 'news_google',
        match: 'EXACT',
        users: [],
        chats: [],
        action: '5e921ad3849247016c3e2e93',
      },
      {
        keyword: 'weather_thessaloniki',
        match: 'EXACT',
        users: [],
        chats: [],
        action: '5e921b6932c12cb3ed20602a',
      },
      {
        keyword: 'sad',
        match: 'INCLUDE',
        users: [],
        chats: [],
        action: '5e921c230779b7f4724e3f66',
      },
      {
        keyword: 'meli',
        match: 'INCLUDE',
        users: [],
        chats: [],
        action: '5e921d6874243209e3d405f1',
      },
    ];
  }

  find() {
    return Promise.resolve(this.intentions);
  }

  findOne({ id }) {
    return Promise.resolve(this.intentions.find((el) => el.id === id));
  }
}

module.exports = {
  IntentionRepository,
};

Listing 10 - bot-core/intentions/intention-repository.js
const { IntentionRepository } = require('./intention-repository');
const { IntentionService } = require('./intention-service');

function createIntentionService() {
  const intentionRepository = new IntentionRepository();
  return new IntentionService({ intentionRepository });
}

module.exports = {
  createIntentionService,
};

Listing 11 - bot-core/intentions/index.js

Next, we remove all the actions from the bot and move them to a separate actions module. We do this to keep the same level of abstraction in the Bot class. Same as above, we create an ActionService, ActionRepository, and an index.js file for the service creation.

class ActionService {
  constructor({
    telegramBotApi,
    openWeatherApi,
    newsApi,
    imgurApi,
    actionRepository,
  }) {
    this.telegramBotApi = telegramBotApi;
    this.openWeatherApi = openWeatherApi;
    this.newsApi = newsApi;
    this.imgurApi = imgurApi;
    this.actionRepository = actionRepository;
    this.functionFactory = {
      sendMessage: this.sendMessage,
      sendPhoto: this.sendPhoto,
      sendDocument: this.sendDocument,
      sendLocation: this.sendLocation,
      sendMessageWithWeatherForCity: this.sendMessageWithWeatherForCity,
      sendMessageArticlesForNewsSource: this.sendMessageArticlesForNewsSource,
    };
  }

  async executeAction({ actionId, chatId, msgId }) {
    const action = await this.actionRepository.findOne({ id: actionId });
    if (!action) {
      throw new Error('Action not found');
    }

    const isValidAction = action.type in this.functionFactory;
    if (!isValidAction) {
      throw new Error('Not valid action type');
    }

    const payload = { ...action.payload, chatId, msgId };
    return this.functionFactory[action.type].call(this, payload);
  }

  sendMessage(payload) {
    const options = {};
    if (payload.message.reply) {
      options.reply_to_message_id = payload.msgId;
    }
    return this.telegramBotApi.sendMessage(payload.chatId, payload.message.text, options);
  }

  sendPhoto(payload) {
    const options = {};
    if (payload.photo.caption) {
      options.caption = payload.photo.caption;
    }
    return this.telegramBotApi.sendPhoto(payload.chatId, payload.photo.photoUrl, options);
  }

  sendDocument(payload) {
    const options = {};
    if (payload.document.caption) {
      options.caption = payload.document.caption;
    }
    return this.telegramBotApi.sendDocument(payload.chatId, payload.document.documentUrl, options);
  }

  sendLocation(payload) {
    const options = {};
    if (payload.location.caption) {
      options.caption = payload.location.caption;
    }
    return this.telegramBotApi.sendLocation(payload.chatId, payload.location, options);
  }

  async sendMessageWithWeatherForCity(payload) {
    const weatherData = await this.openWeatherApi.getWeatherByCity(payload.city, payload.country);
    const text = `<strong>${weatherData.name}: </strong> ${weatherData.weather[0].description}
      <strong>Temperature: </strong>${weatherData.main.temp}°C,  
      <strong>Humidity: </strong>${weatherData.main.humidity}%,
      <strong>Winds: </strong>${weatherData.wind.speed} Bft`;

    const options = {
      parse_mode: 'HTML',
    };

    return this.telegramBotApi.sendMessage(payload.chatId, text, options);
  }

  async sendMessageArticlesForNewsSource(payload) {
    function sendNewsArticleToTelegram(article) {
      const options = {
        parse_mode: 'HTML',
      };
      const text = `<b>${article.title}:</b> ${article.url}`;
      return this.telegramBotApi.sendMessage(payload.chatId, text, options);
    }

    const articles = await this.newsApi.getArticlesBySource(payload.source);
    return Promise.all(articles.map(sendNewsArticleToTelegram.bind(this)));
  }
}

module.exports = {
  ActionService,
};

Listing 12 - bot-core/actions/action-service.js
class ActionRepository {
  constructor() {
    this.actions = [
      {
        id: '5e921abb8db116d84ba09c79',
        type: 'sendMessage',
        payload: {
          message: {
            text: 'NETI',
            reply: true,
          },
        },
      },
      {
        id: '5e921b83dedbf4473708acde',
        type: 'sendMessage',
        payload: {
          message: {
            text: 'Ναι μπαμπά;',
            reply: true,
          },
        },
      },
      {
        id: '5e921ad3849247016c3e2e93',
        type: 'sendMessageArticlesForNewsSource',
        payload: {
          source: 'google-news',
        },
      },
      {
        id: '5e921b6932c12cb3ed20602a',
        type: 'sendMessageWithWeatherForCity',
        payload: {
          city: 'Thessaloniki',
          country: 'gr',
        },
      },
      {
        id: '5e921c230779b7f4724e3f66',
        type: 'sendPhoto',
        payload: {
          photo: {
            caption: 'SAD BOYZ',
            photoUrl: 'https://imgur.com/LeyYNEa',
          },
        },
      },
      {
        id: '5e921d6874243209e3d405f1',
        type: 'sendDocument',
        payload: {
          document: {
            caption: null,
            documentUrl: 'https://imgur.com/0K9r9n0.gif',
          },
        },
      },
    ];
  }

  findOne({ id }) {
    return Promise.resolve(this.actions.find((el) => el.id === id));
  }
}

module.exports = {
  ActionRepository,
};

Listing 13 - bot-core/actions/action-repository.js
const { TelegramBotApi } = require('../../libs/telegram');
const { OpenWeatherApi } = require('../../libs/open-weather-map');
const { NewsApi } = require('../../libs/news-api');
const { ImgurApi } = require('../../libs/imgur');

const { ActionRepository } = require('./action-repository');
const { ActionService } = require('./action-service');

function createActionService() {
  const telegramBotApi = new TelegramBotApi({
    apiToken: process.env.TELEGRAM_BOT_API_TOKEN,
  });

  const openWeatherApi = new OpenWeatherApi({
    apiToken: process.env.OPENWEATHER_API_TOKEN,
    language: 'en',
  });

  const newsApi = new NewsApi({
    apiToken: process.env.NEWS_API_TOKEN,
  });

  const imgurApi = new ImgurApi({
    apiClientId: process.env.IMGUR_CLIENT_ID,
    apiClientSecret: process.env.IMGUR_CLIENT_SECRET,
  });

  const actionRepository = new ActionRepository();
  return new ActionService({
    telegramBotApi,
    openWeatherApi,
    newsApi,
    imgurApi,
    actionRepository,
  });
}

module.exports = {
  createActionService,
};

Listing 14 - bot-core/actions/index.js

Below is how the Bot class looks after extraction:

class Bot {
  constructor({
    actionService,
    intentionService,
  }) {
    this.actionService = actionService;
    this.intentionService = intentionService;
  }

  async reactToUserMessage(message) {
    const intention = await this.intentionService.determineIntention({ text: message.text });
    const determineActionDto = {
      intention,
      chatId: message.chat.id,
      userId: message.from.username,
    };
    const actionId = this.intentionService.determineAction(determineActionDto);
    const executeActionDto = {
      actionId,
      chatId: message.chat.id,
      msgId: message.message_id,
    };
    return this.actionService.executeAction(executeActionDto);
  }
}

module.exports = {
  Bot,
};

Listing 15 - bot-core/bot.js

And the bot factory:

const { Bot } = require('./bot');
const { createIntentionService } = require('./intentions');
const { createActionService } = require('./actions');

function createBot() {
  const intentionService = createIntentionService();
  const actionService = createActionService();
  return new Bot({
    actionService, intentionService,
  });
}

module.exports = {
  createBot,
};

Listing 16 - bot-core/index.js

At last, this is the final structure of the project:

src/bot-core/intentions/intention-service.js
src/bot-core/intentions/intention-repository.js
src/bot-core/intentions/index.js
src/bot-core/actions/action-service.js
src/bot-core/actions/action-repository.js
src/bot-core/actions/index.js
src/bot-core/bot.js
src/bot-core/index.js
src/rest-api/app.js
src/libs/*
main.js
Listing 17 - Final project structure

You can find the final version of the project at this link.

Summary

In this post, we finalized the project refactoring. We restructured the web application and decoupled it from the core business logic of the bot. Afterward, we decomposed the core logic of the chatbot into its essential parts, created dedicated modules for these parts, and re-assembled it. However, we can still do many things to improve it. In the upcoming posts, I will aim to do some of the following:

  • Migrate the project to TypeScript
  • Add proper logging functionality
  • Write unit tests for the most critical parts of the project
  • Create end-to-end tests and setup Continuous Integration
  • Create a data model and add a database
  • Create scheduled tasks, so the bot executes actions based on a schedule you provide.
  • Create REST endpoints that will enable CRUD operations that will allow an Admin to create and modify bot actions and intentions.
  • Create a simple Single Page Application for an Admin dashboard.

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