Building a Slackbot Clone using Firebase Realtime Database & Vue.js - Part 2

Wednesday, March 29, 2017

In the previous part of this series, there was information about how we could use firebase as a data store and created a small utility that can be used to manage messages in our application. Continuing with the creation of the slack bot, in this part we’ll build the bot structure and it’s behaviour. It must be able to store the commands, parse the input and trigger the appropriate command and provide interfaces(like Browser Notification etc) useful for the commands to work.


1. The Bot

The Bot is going to be a constructor function. It has a commands property which is used to store the available commands supported by the bot. The constructor method

/**
 * The Bot
 */
var Bot = function Bot(_) {
  
  // List of available commands the bot can respond to.
  this.commands = [];

  // Initialize the Bot
  this.boot();

};

2. The prototype

We are going to define a set of functions in the prototype chain of the Bot constructor, which will be available to all the object instances created using the Bot constructor.

getCommand function

The getCommand function is used to identify the command object from the list of commands available using the cmd string passed to it.

/**
 * Get the command config object from the commands
 *
 * @param string cmd
 * @return object
 */
Bot.prototype.getCommand = function getCommand(cmd) {
  return _.find(this.commands, function (cmdObj) {
    return _.eq(cmdObj.cmd, cmd);
  });

  return this;
}

deleteCommand function

This function is used to delete a command from the list of available commands, although it is never used in this example.

/**
 * Delete the command from the storage
 * 
 * @param object data
 * @return object
 */
Bot.prototype.deleteCommand = function deleteCommand(data) {
  var cmdObj = this.getCommand(cmd);
  cmdObj.delete(data);

  return this;
}

execCommand function

This method takes the user input, parses it to identify the command and invokes the command’s create method(which will be discussed below). If the command is not identified, it will insert an alert message into the messages, which will be shown to the user.

/**
 * Parse a input string and execute the command
 * 
 * @param string input
 * @return object
 */
Bot.prototype.execCommand = function execCommand(input) {
  var cmd = input.split(' ')[0],
    text = input.substr(cmd.length + 1),
    cmdObj = this.getCommand(cmd);
  
  // Check if the command is valid
  if (cmdObj) {
    cmdObj.create(cmd, text);
  } else {
    this.pushAlertMessage('Oops, i do not recognize that command ¯\\_(ツ)_/¯', 'msg');
  }

  return this;
}

pushAlertMessage function

pushAlertMessage adds a new temporary message into the Vue View Modal’s messages property, which will trigger a UI render and show the message to the user. This message will not be persisted to the storage.

/**
 * Pushes a temporary alert message into the messages to show in the View
 *
 * @param String text The alert message to show.
 * @param String type The alert message type
 */
Bot.prototype.pushAlertMessage = function pushAlertMessage(text, type) {
  // The view instance
  var vm = app;

  vm.$data.messages.push({
    text: text,
    m_type: type,
  });
}

showNotificationAfter function

The showNotificationAfter function is used to create a browser Push Notification, which can set to show after a specified time period.

/**
 * Show's a browser notification at a specific period provided
 *
 * @param String title Notification title
 * @param String body Notification body
 * @param Int period Time the Notification to be shown after
 * @param String sec|min Unit for the period provided
 */
Bot.prototype.showNotificationAfter = function showNotificationAfter(title, body, period, unit) {
  if (unit === 'sec') {
    period = period * 1000;
  } else if (unit === 'min') {
    period = (period *60) * 1000;
  }
  
  if (period !== null || period !== undefined) {
    setTimeout(function () {
      if(window.Notification && Notification.permission !== "denied") {
        Notification.requestPermission(function(status) {
          var n = new Notification(title, { 
            body: body,
            icon: 'assets/bot.png'
          }); 
        });
      }
    }, period);
  }

  return this;
}

3. The boot function

The boot function is the one which creates all the commands for the Bot. It defines a addCommand function which is used to create and insert the command inside the Bot’s commands property.

addCommand function
/**
   * Add a new command for the bot
   *
   * Note: This can also be made available in the bot
   * prototype publically.
   * 
   * Command Format:
   *
   * {
   *    cmd[String]: The unique command identifier e.g /yesno,
   *    desc[String]: A small description of the command,
   *    create[callback]: Will be invoked when the command is being executed
   *      It will recieve the "cmd" and "text" the user input as it's argument,
   *    load[callback]: Used to get the view data object from the data stored of this command type
   * }
   *
   * @param Object options The command config options
   */
  function addCommand(options) {
    if ( !(_.isString(options.cmd) &&
      _.startsWith(options.cmd, '/') &&
      _.isString(options.desc) && 
      _.isFunction(options.create) &&
      _.isFunction(options.load)) ) {
        console.log('Cannot add command, the command options are invalid.');
        return;
    }
    
    bot.commands.push({
      cmd: options.cmd,
      desc: options.desc,
      create: options.create,
      load: options.load
    });
  }

3.1 Command Structure

To create a new command supported by the Bot, a config object containing the details of the command must be passed to the addCommand function. The config object must have the following properties,

  • cmd: String - This is the format to identify the command. It must begin with a / and the command name e.g. /yesno

  • desc: String - This is a short description of the command and the format that must be used to create the command. This will be shown to the user while they are typing.

  • create: function - This function receives two arguments, the cmd and text which is the input provided by the user. Using the user input provided, it must parse and perform the required operation based on the command. The created command data, is stored into the database using the firebase utility provided or using any persistence method.

  • load: function - This function will be passed in the stored data of the message previously created and it must return the data that will be used in the UI to render the message contents.


We’ll now see how to create 3 commands using the format specified above.

3.1.1 The /yesno command

The /yesno command will be used to ask a question which yields either a yes or no answer.

addCommand({
    cmd: '/yesno',
    desc: 'Ask a question which i can answer with just "yes" or "no" ?',
    create: function create(cmd, text) {
      var question = (text.indexOf('?') === -1) ? text + ' ?' : text,
        data = null;

      // Make an ajax call to the yesno.wtf domain
      axios.get('https://yesno.wtf/api', {
          params: {
            question: question
          }
        }).then(function (response) {
          data = response.data;

          if (data !== null) {
            message = {
              text: data.answer,
              m_type: 'cmd',
              cmd: '/yesno',
              timestamp: Date.now(),
              data: data
            };
            // Add the message into the firebase database
            fireUtil.addMessage(message);
          }
        }).catch(function (error) {
          console.log('error occurred ajax yesno - ', error);
          // Add a temp message to the messages stack
          bot.pushAlertMessage('There seems to be a problem communicating with this service, can you try again later.', 'msg');
        });
    },

    load: function (message) {
      return {
        text: message.text,
        type: message.m_type,
        cmd: message.cmd,
        data: {
          image: message.data.image
        }
      }
    }

  });

We pass the config object to the addCommand method with the required properties for the command. The create function in this command takes in the user input text, checks if there is a ? at the end of the text, if not, it appends it.

Once the text is formatted, it send’s an AJAX request to the https://yesno.wtf/api endpoint with the user input and add’s the response as a message data into the firebase database. Once the message is added it triggers a UI update and the command’s load function is used to load the message data for the UI and shown to the user.

3.1.2 The /remind command

The /remind command is used to create a reminder for a specific period of seconds or minutes. The bot will send a browser Push Notification when the reminder time is reached.

addCommand({
    cmd: '/remind',
    desc: 'Set a Reminder - format: /remind {subject} after [number] [sec|min]',
    create: function create(cmd, text) {
      var regex = /(.*)after\s([0-9]+)\s(sec|min)$/g,
        matches = [];
      
      if (matches = regex.exec(text)) {
        var message = {
          text: 'Created a reminder for you.',
          m_type: 'cmd',
          cmd: '/remind',
          timestamp: Date.now(),
          data: {
            subject: matches[1],
            period: matches[2],
            unit: matches[3],
            createdAt: Date.now()
          }
        };
        
        fireUtil.addMessage(message);
      } else {
        bot.pushAlertMessage('I could not recognize the remind command, you do know the format i understand right ?', 'msg');
      }
    },

    load: function (message) {
      var diff = Math.round((new Date(Date.now()) - new Date(message.data.createdAt)) / 1000),
        period = 0;

      if (message.data.unit === 'sec') {
        period = message.data.period - diff;
      } else if (message.data.unit === 'min') {
        period = Math.round(message.data.period - (diff / 60));
      }

      if (period > 0) {
        bot.showNotificationAfter('Bot Reminder', 
          message.data.subject, period, message.data.unit); 
      }

      return {
        text: message.text,
        type: message.m_type,
        cmd: message.cmd,
        data: message.data
      }
    }
    
  });

Same as we created the first command, we pass in the config object with the command signature, description and the required create and load functions.

Here the create function uses Regular Expression to validate the input string of the format required to create the remind command. Once the string is validated, the message data is created and stored in firebase database.

Once stored, it triggers a UI update, which will use the load method to read the message data. The load method uses the bot’s showNotificationAfter method by calculating the time remaining to create a reminder which show’s a browse Push Notification reminding the user of the task created.

3.1.3 The /shorten command

The /shorten command is used to shorten a long URL using the http://goo.gl service. Here we use the API for that service to shorten the URL.

addCommand({
    cmd: '/shorten',
    desc: 'Shorten the URL provided using Google URL Shortener.',
    create: function create(cmd, text) {
      var url = '',
        regExp = new RegExp(/^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/, 'i');

      if ( !regExp.test(text) ) {
        bot.pushAlertMessage('Well, that seems to be an invalid URL format. Can you fix that ?', 'msg');
        return;
      }

      url = text.trim();

      axios.post('https://www.googleapis.com/urlshortener/v1/url?key=AIzaSyBNdsF8XwSOtG9lSvaMJiVG9SAW30mMY4s', {
          longUrl: url
        }).then(function (response) {
          data = response.data;

          if (data !== null) {
            message = {
              text: 'I have shortened your URL (' + url + ') using http://goo.gl',
              m_type: 'cmd',
              cmd: '/shorten',
              data: data,
              timestamp: Date.now()
            };

            fireUtil.addMessage(message);
          }
        }).catch(function (error) {
          console.log('ajax goo.gl error, ', error);
          // Push alert message
          bot.pushAlertMessage('Oops, seems there was some error shortening the url with goo.gl', 'msg');
        });
    },

    load: function (message) {
      return {
        text: message.text,
        type: message.m_type,
        cmd: message.cmd,
        data: message.data
      }
    }

  });

As with the pervious commands, the shorten command’s create function validates the input for the required format, which in this case must be a valid URL. Once it is validated, it send’s a request to the https://www.googleapis.com/urlshortener/ endpoint with the provided URL and get’s back the short url, which is then constructed as a message and stored in the firebase database.

As seen above, creating a command that the bot can respond is very easy, limited with the create and load function.

bot.js
/**
 * The Bot
 */
var Bot = function Bot(_) {
  
  // List of available commands the bot can respond to.
  this.commands = [];

  // Initialize the Bot
  this.boot();

};

/**
 * Delete the command from the storage
 * 
 * @param object data
 * @return object
 */
Bot.prototype.deleteCommand = function deleteCommand(data) {
  var cmdObj = this.getCommand(cmd);
  cmdObj.delete(data);

  return this;
}

/**
 * Get the command config object from the commands
 *
 * @param string cmd
 * @return object
 */
Bot.prototype.getCommand = function getCommand(cmd) {
  return _.find(this.commands, function (cmdObj) {
    return _.eq(cmdObj.cmd, cmd);
  });

  return this;
}

/**
 * Parse a input string and execute the command
 * 
 * @param string input
 * @return object
 */
Bot.prototype.execCommand = function execCommand(input) {
  var cmd = input.split(' ')[0],
    text = input.substr(cmd.length + 1),
    cmdObj = this.getCommand(cmd);
  
  // Check if the command is valid
  if (cmdObj) {
    cmdObj.create(cmd, text);
  } else {
    this.pushAlertMessage('Oops, i do not recognize that command ¯\\_(ツ)_/¯', 'msg');
  }

  return this;
}

/**
 * Show's a browser notification at a specific period provided
 *
 * @param String title Notification title
 * @param String body Notification body
 * @param Int period Time the Notification to be shown after
 * @param String sec|min Unit for the period provided
 */
Bot.prototype.showNotificationAfter = function showNotificationAfter(title, body, period, unit) {
  if (unit === 'sec') {
    period = period * 1000;
  } else if (unit === 'min') {
    period = (period *60) * 1000;
  }
  
  if (period !== null || period !== undefined) {
    setTimeout(function () {
      if(window.Notification && Notification.permission !== "denied") {
        Notification.requestPermission(function(status) {
          var n = new Notification(title, { 
            body: body,
            icon: 'assets/bot.png'
          }); 
        });
      }
    }, period);
  }

  return this;
}

/**
 * Pushes a temporary alert message into the messages to show in the View
 *
 * @param String text The alert message to show.
 * @param String type The alert message type
 */
Bot.prototype.pushAlertMessage = function pushAlertMessage(text, type) {
  // The view instance
  var vm = app;

  vm.$data.messages.push({
    text: text,
    m_type: type,
  });
}

/**
 * The Bot initialization function, 
 *    - Initialize all the commands
 *    - Triggers permission for the Notification if not given already
 */
Bot.prototype.boot = function boot() {
  var bot = this;

  /**
   * Add a new command for the bot
   *
   * Note: This can also be made available in the bot
   * prototype publically.
   * 
   * Command Format:
   *
   * {
   *    cmd[String]: The unique command identifier e.g /yesno,
   *    desc[String]: A small description of the command,
   *    create[callback]: Will be invoked when the command is being executed
   *      It will recieve the "cmd" and "text" the user input as it's argument,
   *    load[callback]: Used to get the view data object from the data stored of this command type
   * }
   *
   * @param Object options The command config options
   */
  function addCommand(options) {
    if ( !(_.isString(options.cmd) &&
      _.startsWith(options.cmd, '/') &&
      _.isString(options.desc) && 
      _.isFunction(options.create) &&
      _.isFunction(options.load)) ) {
        console.log('Cannot add command, the command options are invalid.');
        return;
    }
    
    bot.commands.push({
      cmd: options.cmd,
      desc: options.desc,
      create: options.create,
      load: options.load
    });
  }

  /****** Add the various commands ******/
  
  addCommand({
    cmd: '/yesno',
    desc: 'Ask a question which i can answer with just "yes" or "no" ?',
    create: function create(cmd, text) {
      var question = (text.indexOf('?') === -1) ? text + ' ?' : text,
        data = null;

      // Make an ajax call to the yesno.wtf domain
      axios.get('https://yesno.wtf/api', {
          params: {
            question: question
          }
        }).then(function (response) {
          data = response.data;

          if (data !== null) {
            message = {
              text: data.answer,
              m_type: 'cmd',
              cmd: '/yesno',
              timestamp: Date.now(),
              data: data
            };
            // Add the message into the firebase database
            fireUtil.addMessage(message);
          }
        }).catch(function (error) {
          console.log('error occurred ajax yesno - ', error);
          // Add a temp message to the messages stack
          bot.pushAlertMessage('There seems to be a problem communicating with this service, can you try again later.', 'msg');
        });
    },

    load: function (message) {
      return {
        text: message.text,
        type: message.m_type,
        cmd: message.cmd,
        data: {
          image: message.data.image
        }
      }
    }

  });

  addCommand({
    cmd: '/remind',
    desc: 'Set a Reminder - format: /remind {subject} after [number] [sec|min]',
    create: function create(cmd, text) {
      var regex = /(.*)after\s([0-9]+)\s(sec|min)$/g,
        matches = [];
      
      if (matches = regex.exec(text)) {
        var message = {
          text: 'Created a reminder for you.',
          m_type: 'cmd',
          cmd: '/remind',
          timestamp: Date.now(),
          data: {
            subject: matches[1],
            period: matches[2],
            unit: matches[3],
            createdAt: Date.now()
          }
        };
        
        fireUtil.addMessage(message);
      } else {
        bot.pushAlertMessage('I could not recognize the remind command, you do know the format i understand right ?', 'msg');
      }
    },

    load: function (message) {
      var diff = Math.round((new Date(Date.now()) - new Date(message.data.createdAt)) / 1000),
        period = 0;

      if (message.data.unit === 'sec') {
        period = message.data.period - diff;
      } else if (message.data.unit === 'min') {
        period = Math.round(message.data.period - (diff / 60));
      }

      if (period > 0) {
        bot.showNotificationAfter('Bot Reminder', 
          message.data.subject, period, message.data.unit); 
      }

      return {
        text: message.text,
        type: message.m_type,
        cmd: message.cmd,
        data: message.data
      }
    }
    
  });

  addCommand({
    cmd: '/shorten',
    desc: 'Shorten the URL provided using Google URL Shortener.',
    create: function create(cmd, text) {
      var url = '',
        regExp = new RegExp(/^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/, 'i');

      if ( !regExp.test(text) ) {
        bot.pushAlertMessage('Well, that seems to be an invalid URL format. Can you fix that ?', 'msg');
        return;
      }

      url = text.trim();

      axios.post('https://www.googleapis.com/urlshortener/v1/url?key=AIzaSyBNdsF8XwSOtG9lSvaMJiVG9SAW30mMY4s', {
          longUrl: url
        }).then(function (response) {
          data = response.data;

          if (data !== null) {
            message = {
              text: 'I have shortened your URL (' + url + ') using http://goo.gl',
              m_type: 'cmd',
              cmd: '/shorten',
              data: data,
              timestamp: Date.now()
            };

            fireUtil.addMessage(message);
          }
        }).catch(function (error) {
          console.log('ajax goo.gl error, ', error);
          // Push alert message
          bot.pushAlertMessage('Oops, seems there was some error shortening the url with goo.gl', 'msg');
        });
    },

    load: function (message) {
      return {
        text: message.text,
        type: message.m_type,
        cmd: message.cmd,
        data: message.data
      }
    }

  });

  // Check if notificaiton is enabled.
  if(window.Notification && Notification.permission === "denied") {
    Notification.requestPermission(function(status) {  // status is "granted", if accepted by user
      var n = new Notification('Slackbot', { 
        body: 'Hey there, i can now remind you with notifications',
        icon: '' // optional
      }); 
    });
  }

}

/**
 * Create a new Bot instance and add the supported commands
 */
var slackbot = new Bot(_);


Read Next - Building a Slack Bot clone using Firebase and Vue.js - Part 3