mylisabox/trailpack-chatbot

View on GitHub
api/services/ChatBotService.js

Summary

Maintainability
F
4 days
Test Coverage
'use strict'

const Service = require('trails/service')
const _ = require('lodash')

const mapParams = {
  'number': '([0-9]+)',
  'text': '([a-zA-Z]+)',
  'fulltext': '([0-9a-zA-Z ]+)',
  'acceptance': '^(ok|oui|yes|ouai|no problem|aucun problème|ça marche|it\'s ok|sure|volontier|let\'s do this)$',
}

/**
 * @module ChatBotService
 * @description Manage chatbots
 */
module.exports = class ChatBotService extends Service {
  _prepareParam(key, value) {
    if (Array.isArray(value)) {
      mapParams[key] = `(${value.join('|')})`
    }
    else if (_.isFunction(value)) {
      return value(this.app).then(result => {
        if (Array.isArray(result)) {
          mapParams[key] = `(${result.join('|')})`
        }
        else {
          mapParams[key] = result
        }
      }).catch(err => mapParams[key] = null)
    }
    else if (_.isPlainObject(value)) {
      let keys = Object.keys(value)
      if (value[keys[0]].keywords) {
        let allKeywords = []
        for (const keyParam of keys) {
          allKeywords = allKeywords.concat(value[keyParam].keywords)
        }
        keys = allKeywords
      }

      mapParams[key] = `(${keys.join('|')})`
    }
    else {
      mapParams[key] = value
    }
    return Promise.resolve()
  }

  _prepareParams() {
    _.merge(mapParams, this.app.config.chatbot.params)
    const promises = []
    _.each(mapParams, (value, key) => {
      promises.push(this._prepareParam(key, value))
    })
    return Promise.all(promises)
  }

  /**
   * Compile sentences to change fields into regexp
   * @param sentences to compile
   * @returns {{}}
   * @private
   */
  _compileSentences(sentences) {
    const compiledSentences = {}
    _.each(sentences, (sentences, lang) => {
      compiledSentences[lang] = []
      for (let i = 0; i < sentences.length; i++) {
        const paramsName = []
        const sentence = sentences[i].replace(/(%[a-zA-Z_]+%)/gi, function (matched) {
          matched = matched.replace(/%/g, '')
          const parts = matched.split('_')
          if (parts.length > 1) {
            paramsName.push(parts[1])
          }
          else {
            paramsName.push(parts[0])
          }
          return mapParams[parts[0]] || '([a-zA-z]+)'
        })
        compiledSentences[lang][i] = {
          sentence: sentence,
          fields: paramsName
        }
      }
    })
    return compiledSentences
  }

  /**
   * Prepare chatBot data before saving in database
   * @param botId
   * @param botData
   * @private
   */
  _prepareChatBot(botId, botData) {
    let data
    if (botData.originalData) {
      botData.data.links = botData.originalData.links
      botData.data.freeStates = botData.originalData.freeStates
      botData.data.nestedStates = botData.originalData.nestedStates
      _.each(botData.data.links, link => {
        link.compiledSentences = this._compileSentences(link.sentences)
      })

      _.each(botData.data.freeStates, (dataState, id) => {
        dataState.id = id
        dataState.compiledSentences = this._compileSentences(dataState.sentences)
        dataState.links = botData.data.links.filter(link => {
          return link.from === id
        })
      })

      _.each(botData.data.nestedStates, (dataState, id) => {
        dataState.id = id
        dataState.links = botData.data.links.filter(link => {
          return link.from === id
        })
      })

      data = botData
    }
    else {
      data = _.cloneDeep(botData)
      data.displayName = botData.name
      data.name = botId
      _.each(data.links, link => {
        link.compiledSentences = this._compileSentences(link.sentences)
      })

      _.each(data.freeStates, (dataState, id) => {
        dataState.id = id
        dataState.compiledSentences = this._compileSentences(dataState.sentences)
        dataState.links = data.links.filter(link => {
          return link.from === id
        })
      })

      _.each(data.nestedStates, (dataState, id) => {
        dataState.id = id
        dataState.links = data.links.filter(link => {
          return link.from === id
        })
      })
      data = _.merge({
        name: botId,
        displayName: botData.name,
        data: data,
        originalData: {
          links: botData.links,
          freeStates: botData.freeStates,
          nestedStates: botData.nestedStates
        }
      }, _.omit(botData, ['name', 'links', 'freeStates', 'nestedStates']))
    }
    return data
  }

  /**
   * Compile and save chatBot into DB
   * @param initialData
   * @returns {Promise.<ChatBot>}
   */
  init(initialData) {
    this.botCache = this.app.services.CacheService.getStore('chatbot')
    initialData = _.cloneDeep(initialData)
    this.chatBots = []
    return this._prepareParams().then(() => this.app.orm.ChatBot.findAll({
      where: {
        enabled: true
      }
    }).then(results => {
      const bots = []
      _.forEach(initialData, (botData, botId) => {
        bots.push(this._prepareChatBot(botId, botData))
      })

      if (!results || results.length === 0) {
        return this.app.orm.ChatBot.bulkCreate(bots).then(results => {
          this.chatBots = results || []
          return results
        })
      }
      else {
        const updates = []
        for (const bot of bots) {
          if (results.filter(result => result.name === bot.name.toLowerCase()).length > 0) {
            updates.push(this.app.orm.ChatBot.update(bot, { where: { name: bot.name } }))
          }
          else {
            updates.push(this.app.orm.ChatBot.create(bot))
          }
        }
        return Promise.all(updates).then(results => this.reloadBots())
      }
    }))
  }

  /**
   * Add a new bot into DB
   * @param botId
   * @param botData
   * @returns {Promise.<TResult>}
   */
  addBot(botId, botData) {
    return this._prepareParams().then(() => this.app.orm.ChatBot.create(this._prepareChatBot(botId, botData))
      .then(result => {
        this.chatBots.push(result)
        return result
      }))
  }

  /**
   * Delete a bot in DB
   * @param botId
   * @returns {Promise.<TResult>}
   */
  deleteBot(botId) {
    return this.app.orm.ChatBot.destroy({ where: { name: botId } }).then(result => {
      const index = _.indexOf(this.chatBots, _.find(this.chatBots, { name: botId }))
      this.chatBots.splice(index, 1)
      return result
    })
  }

  /**
   * Update a bot in DB
   * @param botId
   * @param botData
   * @returns {Promise.<TResult>}
   */
  updateBot(botId, botData) {
    return this.app.orm.ChatBot.update(this._prepareChatBot(botId, botData), { where: { name: botId } }).then(result => {
      this.reloadBot(botId)
      return result
    })
  }

  /**
   * Reload a specific bot
   * @param botId
   * @returns {Promise.<TResult>}
   */
  reloadBot(botId) {
    return this.app.orm.ChatBot.find({
      where: {
        name: botId
      }
    }).then(result => {
      if (result) {
        // Find item index using indexOf+find
        const index = _.indexOf(this.chatBots, _.find(this.chatBots, { name: botId }))
        this.chatBots.splice(index, 1, result)
      }
      return result
    })
  }

  reloadBots() {
    return this._prepareParams().then(() => this.app.orm.ChatBot.findAll({
      where: {
        enabled: true
      }
    }).then(results => {
      if (results) {
        const bots = []
        _.forEach(results, (botData) => {
          const data = this._prepareChatBot(botData.name, botData.toJSON())
          bots.push(this.app.orm.ChatBot.update(
            data,
            {
              where: {
                name: botData.name
              }
            }
          ))
        })
        return Promise.all(bots).then(results => {
          return this.app.orm.ChatBot.findAll({
            where: {
              enabled: true
            }
          }).then(results => {
            this.chatBots = results || []
            return results
          })
        })
      }
      return results
    }))
  }

  /**
   * Search if the user sentence match a sentence from the state
   * @param bot
   * @param stateData
   * @param lang
   * @param userSentence
   * @param sentences
   * @returns {*}
   * @private
   */
  _searchMatch(bot, stateData, lang, userSentence, sentences) {
    let results = null
    if (sentences[lang]) {
      for (let j = 0; j < sentences[lang].length; j++) {
        const sentenceData = sentences[lang][j]
        const reg = new RegExp(sentenceData.sentence, 'gi')

        const matches = reg.exec(userSentence)
        if (matches) {
          const fields = {}
          if (matches.length > 1) {
            for (let i = 1; i < matches.length; i++) {
              let value = matches[i]
              const type = sentenceData.fields[i - 1]
              const param = this.app.config.chatbot.params[type]
              if (type.indexOf('number') !== -1) {
                value = parseInt(value)
              }
              else if (_.isPlainObject(param)) {
                const keys = Object.keys(param)
                if (param[keys[0]].keywords) {
                  for (const key of keys) {
                    const filteredParam = param[key].keywords.filter(keyword => keyword === value)
                    if (filteredParam.length === 1) {
                      value = key
                      break
                    }
                  }
                }
                else {
                  value = param[value]
                }
              }
              fields[type] = value
            }
          }
          results = {
            botId: bot.name,
            bot: bot.toJSON ? bot.toJSON() : bot,
            action: stateData ? stateData.id : null,
            state: stateData,
            responses: stateData && stateData.responses ? stateData.responses[lang] : [],
            response: stateData && stateData.responses ? _.sample(stateData.responses[lang]) : '',
            lang: lang,
            userSentence: userSentence,
            match: matches,
            fields: fields
          }
          break
        }
      }
    }
    return results
  }

  /**
   * Search if the sentence match a sentence from the free states of the bot
   * @param bot
   * @param lang
   * @param userSentence
   * @returns {*}
   * @private
   */
  _searchMatchForFreeStates(bot, lang, userSentence) {
    const keys = Object.keys(bot.data['freeStates'])
    let results = null
    for (let i = 0; i < keys.length; i++) {
      const stateId = keys[i]
      const stateData = bot.data.freeStates[stateId]
      stateData.id = stateId

      results = this._searchMatch(bot, stateData, lang, userSentence, stateData.compiledSentences)

      if (results) {
        break
      }
    }
    return results
  }

  /**
   * Ask to process user sentence
   * @param userId
   * @param lang
   * @param userSentence
   * @param chatBotId - optional - search into a specific bot only
   * @returns {Promise} processed data if found
   */
  interact(userId, lang, userSentence, chatBotId) {
    if (!userId && !this.app.config.chatbot.allowAnonymousUsers) {
      return Promise.reject('No user provided')
    }

    return new Promise((resolve, reject) => {
      this.botCache.get(userId + '_data', (err, data) => {
        if (err) {
          reject(err)
        }
        else {
          let result
          // if previous data exist, we search for nested states first
          if (data) {
            const bot = data.bot

            for (let i = 0; i < data.state.links.length; i++) {
              const link = data.state.links[i]

              result = this._searchMatch(bot, null, lang, userSentence, link.compiledSentences)

              if (result) {
                result.state = bot.data.nestedStates[link.to]
                result.action = result.state.id
                break
              }
            }
          }

          // no result in nested data or we want to test free states
          if (!result) {
            if (chatBotId) {
              const bot = _.find(this.chatBots, { name: chatBotId })
              if (bot) {
                result = this._searchMatchForFreeStates(bot, lang, userSentence)
              }
              else {
                reject(new Error('unknow bot ' + chatBotId))
              }
            }
            else {
              for (let i = 0; i < this.chatBots.length; i++) {
                const bot = this.chatBots[i]
                result = this._searchMatchForFreeStates(bot, lang, userSentence)
                if (result) {
                  break
                }
              }
            }
          }

          if (result) {
            const hook = this.app.config.chatbot.hooks[result.action]
            const publicResult = _.omit(result, ['bot', 'state', 'match'])
            if (userId) {
              this.botCache.set(userId + '_data', result, (err) => {
                if (err) {
                  reject(err)
                }
                else {
                  if (hook) {
                    hook(this.app, publicResult).then(resolve).catch(reject)
                  }
                  else {
                    resolve(publicResult)
                  }
                }
              })
            }
            else {
              if (hook) {
                hook(this.app, publicResult).then(resolve).catch(reject)
              }
              else {
                resolve(publicResult)
              }
            }
          }
          else {
            const defaultAnswer = this.app.config.chatbot.defaultAnswer
            if (defaultAnswer) {
              defaultAnswer(this.app, {
                userId: userId,
                userSentence: userSentence,
                botId: chatBotId,
                lang: lang
              }).then(resolve).catch(reject)
            }
            else {
              resolve({
                action: 'UNKNOWN',
                userId: userId,
                userSentence: userSentence,
                botId: chatBotId,
                lang: lang
              })
            }
          }
        }
      })
    })
  }
}