lib/botkit/CoreBot.js
function Conversation(task, message) {

    this.messages = [];
    this.sent = [];
    this.transcript = [];

    this.context = {
      user: message.user,
      channel: message.channel,
      bot: task.bot,
    };

    this.events = {};

    this.vars = {};

    this.threads = {};
    this.thread = null;

    this.status = 'new';
    this.task = task;
    this.source_message = message;
    this.handler = null;
    this.responses = {};
    this.capture_options = {};
    this.startTime = new Date();
    this.lastActive = new Date();
    /** will be pointing to a callback which will be called after timeout,
     * conversation will be not be ended and should be taken care by callback
     */
    this.timeOutHandler = null;

    this.collectResponse = function (key, value) {
      this.responses[key] = value;
    };

    this.capture = function (response, cb) {

      var that = this;
      var capture_key = this.sent[this.sent.length - 1].text;
      botkit.middleware.capture.run(that.task.bot, response, that, function (err, bot, response, convo) {
        if (response.text) {
          response.text = response.text.trim();
        } else {
          response.text = '';
        }

        if (that.capture_options.key != undefined) {
          capture_key = that.capture_options.key;
        }

        // capture the question that was asked
        // if text is an array, get 1st
        if (typeof (that.sent[that.sent.length - 1].text) == 'string') {
          response.question = that.sent[that.sent.length - 1].text;
        } else if (Array.isArray(that.sent[that.sent.length - 1].text)) {
          response.question = that.sent[that.sent.length - 1].text[0];
        } else {
          response.question = '';
        }

        if (that.capture_options.multiple) {
          if (!that.responses[capture_key]) {
            that.responses[capture_key] = [];
          }
          that.responses[capture_key].push(response);
        } else {
          that.responses[capture_key] = response;
        }

        if (cb) cb(response);
      });

    };

    this.handle = function (message) {

      var that = this;
      this.lastActive = new Date();
      this.transcript.push(message);
      botkit.logger.debug('HANDLING MESSAGE IN CONVO', message);
      // do other stuff like call custom callbacks
      if (this.handler) {
        this.capture(message, function (message) {
          // if the handler is a normal function, just execute it!
          // NOTE: anyone who passes in their own handler has to call
          // convo.next() to continue after completing whatever it is they want to do.
          if (typeof (that.handler) == 'function') {
            that.handler(message, that);
          } else {
            // handle might be a mapping of keyword to callback.
            // lets see if the message matches any of the keywords
            let match, patterns = that.handler;
            for (let p = 0; p < patterns.length; p++) {
              if (patterns[p].pattern && botkit.hears_test([patterns[p].pattern], message)) {
                botkit.middleware.heard.run(that.task.bot, message, function (err, bot, message) {
                  patterns[p].callback(message, that);
                });
                return;
              }
            }

            // none of the messages matched! What do we do?
            // if a default exists, fire it!
            for (let p = 0; p < patterns.length; p++) {
              if (patterns[p].default) {
                botkit.middleware.heard.run(that.task.bot, message, function (err, bot, message) {
                  patterns[p].callback(message, that);
                });
                return;
              }
            }

          }
        });
      } else {
        // do nothing
      }
    };

    this.setVar = function (field, value) {
      if (!this.vars) {
        this.vars = {};
      }
      this.vars[field] = value;
    };

    this.activate = function () {
      this.task.trigger('conversationStarted', [this]);
      this.task.botkit.trigger('conversationStarted', [this.task.bot, this]);
      this.status = 'active';
    };

    /**
     * active includes both ACTIVE and ENDING
     * in order to allow the timeout end scripts to play out
     **/
    this.isActive = function () {
      return (this.status == 'active' || this.status == 'ending');
    };

    this.deactivate = function () {
      this.status = 'inactive';
    };

    this.say = function (message) {
      this.addMessage(message);
    };

    this.sayFirst = function (message) {
      if (typeof (message) == 'string') {
        message = {
          text: message,
          channel: this.source_message.channel,
        };
      } else {
        message.channel = this.source_message.channel;
      }
      this.messages.unshift(message);
    };


    this.on = function (event, cb) {
      botkit.logger.debug('Setting up a handler for', event);
      var events = event.split(/\,/g);
      for (var e in events) {
        if (!this.events[events[e]]) {
          this.events[events[e]] = [];
        }
        this.events[events[e]].push(cb);
      }
      return this;
    };

    this.trigger = function (event, data) {
      if (this.events[event]) {
        for (var e = 0; e < this.events[event].length; e++) {
          var res = this.events[event][e].apply(this, data);
          if (res === false) {
            return;
          }
        }
      } else { }
    };

    // proceed to the next message after waiting for an answer
    this.next = function () {
      this.handler = null;
    };

    this.repeat = function () {
      if (this.sent.length) {
        this.messages.push(this.sent[this.sent.length - 1]);
      } else {
        // console.log('TRIED TO REPEAT, NOTHING TO SAY');
      }
    };

    this.silentRepeat = function () {
      return;
    };

    this.addQuestion = function (message, cb, capture_options, thread) {
      if (typeof (message) == 'string') {
        message = {
          text: message,
          channel: this.source_message.channel
        };
      } else {
        message.channel = this.source_message.channel;
      }

      if (capture_options) {
        message.capture_options = capture_options;
      }

      message.handler = cb;
      this.addMessage(message, thread);
    };


    this.ask = function (message, cb, capture_options) {
      this.addQuestion(message, cb, capture_options, this.thread || 'default');
    };

    this.addMessage = function (message, thread) {
      if (!thread) {
        thread = this.thread;
      }
      if (typeof (message) == 'string') {
        message = {
          text: message,
          channel: this.source_message.channel,
        };
      } else {
        message.channel = this.source_message.channel;
      }

      if (!this.threads[thread]) {
        this.threads[thread] = [];
      }
      this.threads[thread].push(message);

      // this is the current topic, so add it here as well
      if (this.thread == thread) {
        this.messages.push(message);
      }
    };

    // how long should the bot wait while a user answers?
    this.setTimeout = function (timeout) {
      this.task.timeLimit = timeout;
    };

    // For backwards compatibility, wrap gotoThread in its previous name
    this.changeTopic = function (topic) {
      this.gotoThread(topic);
    };

    this.hasThread = function (thread) {
      return (this.threads[thread] != undefined);
    };


    this.transitionTo = function (thread, message) {

      // add a new transition thread
      // add this new message to it
      // set that message action to execute the actual transition
      // then change threads to transition thread

      var num = 1;
      while (this.hasThread('transition_' + num)) {
        num++;
      }

      var threadname = 'transition_' + num;

      if (typeof (message) == 'string') {
        message = {
          text: message,
          action: thread
        };
      } else {
        message.action = thread;
      }

      this.addMessage(message, threadname);

      this.gotoThread(threadname);

    };

    this.beforeThread = function (thread, callback) {
      if (!this.before_hooks) {
        this.before_hooks = {};
      }

      if (!this.before_hooks[thread]) {
        this.before_hooks[thread] = [];
      }
      this.before_hooks[thread].push(callback);
    };

    this.gotoThread = function (thread) {
      var that = this;
      that.next_thread = thread;
      that.processing = true;

      var makeChange = function () {
        if (!that.hasThread(that.next_thread)) {
          if (that.next_thread == 'default') {
            that.threads[that.next_thread] = [];
          } else {
            botkit.logger.debug('WARN: gotoThread() to an invalid thread!', thread);
            that.stop('unknown_thread');
            return;
          }
        }

        that.thread = that.next_thread;
        that.messages = that.threads[that.next_thread].slice();

        that.handler = null;
        that.processing = false;
      };

      if (that.before_hooks && that.before_hooks[that.next_thread]) {

        // call any beforeThread hooks in sequence
        async.eachSeries(this.before_hooks[that.next_thread], function (before_hook, next) {
          before_hook(that, next);
        }, function (err) {
          if (!err) {
            makeChange();
          }
        });

      } else {

        makeChange();

      }

    };

    this.combineMessages = function (messages) {
      if (!messages) {
        return '';
      }

      if (Array.isArray(messages) && !messages.length) {
        return '';
      }

      if (messages.length > 1) {
        var txt = [];
        var last_user = null;
        var multi_users = false;
        last_user = messages[0].user;
        for (let x = 0; x < messages.length; x++) {
          if (messages[x].user != last_user) {
            multi_users = true;
          }
        }
        last_user = '';
        for (let x = 0; x < messages.length; x++) {
          if (multi_users && messages[x].user != last_user) {
            last_user = messages[x].user;
            if (txt.length) {
              txt.push('');
            }
            txt.push('<@' + messages[x].user + '>:');
          }
          txt.push(messages[x].text);
        }
        return txt.join('\n');
      } else {
        if (messages.length) {
          return messages[0].text;
        } else {
          return messages.text;
        }
      }
    };

    this.getResponses = function () {

      var res = {};
      for (var key in this.responses) {

        res[key] = {
          question: this.responses[key].length ?
            this.responses[key][0].question : this.responses[key].question,
          key: key,
          answer: this.extractResponse(key),
        };
      }
      return res;
    };

    this.getResponsesAsArray = function () {

      var res = [];
      for (var key in this.responses) {

        res.push({
          question: this.responses[key].length ?
            this.responses[key][0].question : this.responses[key].question,
          key: key,
          answer: this.extractResponse(key),
        });
      }
      return res;
    };


    this.extractResponses = function () {

      var res = {};
      for (var key in this.responses) {
        res[key] = this.extractResponse(key);
      }
      return res;
    };

    this.extractResponse = function (key) {
      return this.combineMessages(this.responses[key]);
    };

    this.replaceAttachmentTokens = function (attachments) {

      if (attachments && attachments.length) {
        for (let a = 0; a < attachments.length; a++) {
          for (let key in attachments[a]) {
            if (typeof (attachments[a][key]) == 'string') {
              attachments[a][key] = this.replaceTokens(attachments[a][key]);
            } else {
              attachments[a][key] = this.replaceAttachmentTokens(attachments[a][key]);
            }
          }
        }
      } else {
        for (let a in attachments) {
          if (typeof (attachments[a]) == 'string') {
            attachments[a] = this.replaceTokens(attachments[a]);
          } else {
            attachments[a] = this.replaceAttachmentTokens(attachments[a]);
          }
        }
      }

      return attachments;
    };


    this.replaceTokens = function (text) {

      var vars = {
        identity: this.task.bot.identity,
        responses: this.extractResponses(),
        origin: this.task.source_message,
        vars: this.vars,
      };

      var rendered = '';

      try {
        rendered = mustache.render(text, vars);
      } catch (err) {
        botkit.logger.error('Error in message template. Mustache failed with error: ', err);
        rendered = text;
      }

      return rendered;
    };

    this.stop = function (status) {
      this.handler = null;
      this.messages = [];
      this.status = status || 'stopped';
      botkit.logger.debug('Conversation is over with status ' + this.status);
      this.task.conversationEnded(this);
    };

    // was this conversation successful?
    // return true if it was completed
    // otherwise, return false
    // false could indicate a variety of failed states:
    // manually stopped, timed out, etc
    this.successful = function () {

      // if the conversation is still going, it can't be successful yet
      if (this.isActive()) {
        return false;
      }

      if (this.status == 'completed') {
        return true;
      } else {
        return false;
      }

    };

    this.cloneMessage = function (message) {
      // clone this object so as not to modify source
      var outbound = clone(message);

      if (typeof (message.text) == 'string') {
        outbound.text = this.replaceTokens(message.text);
      } else if (message.text) {
        outbound.text = this.replaceTokens(
          message.text[Math.floor(Math.random() * message.text.length)]
        );
      }

      if (outbound.attachments) {
        outbound.attachments = this.replaceAttachmentTokens(outbound.attachments);
      }

      if (outbound.attachment) {

        // pick one variation of the message text at random
        if (outbound.attachment.payload.text && typeof (outbound.attachment.payload.text) != 'string') {
          outbound.attachment.payload.text = this.replaceTokens(
            outbound.attachment.payload.text[
            Math.floor(Math.random() * outbound.attachment.payload.text.length)
            ]
          );
        }
        outbound.attachment = this.replaceAttachmentTokens([outbound.attachment])[0];
      }

      if (this.messages.length && !message.handler) {
        outbound.continue_typing = true;
      }

      if (typeof (message.attachments) == 'function') {
        outbound.attachments = message.attachments(this);
      }

      return outbound;
    };

    this.onTimeout = function (handler) {
      if (typeof (handler) == 'function') {
        this.timeOutHandler = handler;
      } else {
        botkit.logger.debug('Invalid timeout function passed to onTimeout');
      }
    };

    this.tick = function () {
      var now = new Date();

      if (this.isActive()) {
        if (this.processing) {
          // do nothing. The bot is waiting for async process to complete.
        } else if (this.handler) {
          // check timeout!
          // how long since task started?
          var duration = (now.getTime() - this.task.startTime.getTime());
          // how long since last active?
          var lastActive = (now.getTime() - this.lastActive.getTime());

          if (this.task.timeLimit && // has a timelimit
            (duration > this.task.timeLimit) && // timelimit is up
            (lastActive > this.task.timeLimit) // nobody has typed for 60 seconds at least
          ) {
            // if timeoutHandler is set then call it, otherwise follow the normal flow
            // this will not break others code, after the update
            if (this.timeOutHandler) {
              this.timeOutHandler(this);
            } else if (this.hasThread('on_timeout')) {
              this.status = 'ending';
              this.gotoThread('on_timeout');
            } else {
              this.stop('timeout');
            }
          }
          // otherwise do nothing
        } else {
          if (this.messages.length) {

            if (this.sent.length &&
              !this.sent[this.sent.length - 1].sent
            ) {
              return;
            }

            if (this.task.bot.botkit.config.require_delivery &&
              this.sent.length &&
              !this.sent[this.sent.length - 1].delivered
            ) {
              return;
            }

            if (typeof (this.messages[0].timestamp) == 'undefined' ||
              this.messages[0].timestamp <= now.getTime()) {
              var message = this.messages.shift();
              //console.log('HANDLING NEW MESSAGE',message);
              // make sure next message is delayed appropriately
              if (this.messages.length && this.messages[0].delay) {
                this.messages[0].timestamp = now.getTime() + this.messages[0].delay;
              }
              if (message.handler) {
                //console.log(">>>>>> SET HANDLER IN TICK");
                this.handler = message.handler;
              } else {
                this.handler = null;
                //console.log(">>>>>>> CLEARING HANDLER BECAUSE NO HANDLER NEEDED");
              }
              if (message.capture_options) {
                this.capture_options = message.capture_options;
              } else {
                this.capture_options = {};
              }

              this.lastActive = new Date();

              // is there any text?
              // or an attachment? (facebook)
              // or multiple attachments (slack)
              // if (message.text || message.attachments || message.attachment) {
              if (message) {

                var outbound = this.cloneMessage(message);
                var that = this;

                outbound.sent_timestamp = new Date().getTime();

                that.sent.push(outbound);
                that.transcript.push(outbound);

                this.task.bot.reply(this.source_message, outbound, function (err, sent_message) {
                  if (err) {
                    botkit.logger.error('An error occurred while sending a message: ', err);

                    // even though an error occured, set sent to true
                    // this will allow the conversation to keep going even if one message fails
                    // TODO: make a message that fails to send _resend_ at least once
                    that.sent[that.sent.length - 1].sent = true;
                    that.sent[that.sent.length - 1].api_response = err;

                  } else {

                    that.sent[that.sent.length - 1].sent = true;
                    that.sent[that.sent.length - 1].api_response = sent_message;

                    // if sending via slack's web api, there is no further confirmation
                    // so we can mark the message delivered
                    if (that.task.bot.type == 'slack' && sent_message && sent_message.ts) {
                      that.sent[that.sent.length - 1].delivered = true;
                    }

                    that.trigger('sent', [sent_message]);

                  }
                });
              }
              if (message.action) {
                if (typeof (message.action) == 'function') {
                  message.action(this);
                } else if (message.action == 'repeat') {
                  this.repeat();
                } else if (message.action == 'wait') {
                  this.silentRepeat();
                } else if (message.action == 'stop') {
                  this.stop();
                } else if (message.action == 'timeout') {
                  this.stop('timeout');
                } else if (this.threads[message.action]) {
                  this.gotoThread(message.action);
                }
              }
            } else {
              //console.log('Waiting to send next message...');
            }

            // end immediately instad of waiting til next tick.
            // if it hasn't already been ended by a message action!
            if (this.isActive() && !this.messages.length && !this.handler && !this.processing) {
              this.stop('completed');
            }

          } else if (this.sent.length) { // sent at least 1 message
            this.stop('completed');
          }
        }
      }
    };

    botkit.logger.debug('CREATED A CONVO FOR', this.source_message.user, this.source_message.channel);
    this.gotoThread('default');
  }
function Conversation(task, message) {

    this.messages = [];
    this.sent = [];
    this.transcript = [];

    this.context = {
      user: message.user,
      channel: message.channel,
      bot: task.bot,
    };

    this.events = {};

    this.vars = {};

    this.threads = {};
    this.thread = null;

    this.status = 'new';
    this.task = task;
    this.source_message = message;
    this.handler = null;
    this.responses = {};
    this.capture_options = {};
    this.startTime = new Date();
    this.lastActive = new Date();
    /** will be pointing to a callback which will be called after timeout,
     * conversation will be not be ended and should be taken care by callback
     */
    this.timeOutHandler = null;

    this.collectResponse = function (key, value) {
      this.responses[key] = value;
    };

    this.capture = function (response, cb) {

      var that = this;
      var capture_key = this.sent[this.sent.length - 1].text;
      botkit.middleware.capture.run(that.task.bot, response, that, function (err, bot, response, convo) {
        if (response.text) {
          response.text = response.text.trim();
        } else {
          response.text = '';
        }

        if (that.capture_options.key != undefined) {
          capture_key = that.capture_options.key;
        }

        // capture the question that was asked
        // if text is an array, get 1st
        if (typeof (that.sent[that.sent.length - 1].text) == 'string') {
          response.question = that.sent[that.sent.length - 1].text;
        } else if (Array.isArray(that.sent[that.sent.length - 1].text)) {
          response.question = that.sent[that.sent.length - 1].text[0];
        } else {
          response.question = '';
        }

        if (that.capture_options.multiple) {
          if (!that.responses[capture_key]) {
            that.responses[capture_key] = [];
          }
          that.responses[capture_key].push(response);
        } else {
          that.responses[capture_key] = response;
        }

        if (cb) cb(response);
      });

    };

    this.handle = function (message) {

      var that = this;
      this.lastActive = new Date();
      this.transcript.push(message);
      botkit.logger.debug('HANDLING MESSAGE IN CONVO', message);
      // do other stuff like call custom callbacks
      if (this.handler) {
        this.capture(message, function (message) {
          // if the handler is a normal function, just execute it!
          // NOTE: anyone who passes in their own handler has to call
          // convo.next() to continue after completing whatever it is they want to do.
          if (typeof (that.handler) == 'function') {
            that.handler(message, that);
          } else {
            // handle might be a mapping of keyword to callback.
            // lets see if the message matches any of the keywords
            let match, patterns = that.handler;
            for (let p = 0; p < patterns.length; p++) {
              if (patterns[p].pattern && botkit.hears_test([patterns[p].pattern], message)) {
                botkit.middleware.heard.run(that.task.bot, message, function (err, bot, message) {
                  patterns[p].callback(message, that);
                });
                return;
              }
            }

            // none of the messages matched! What do we do?
            // if a default exists, fire it!
            for (let p = 0; p < patterns.length; p++) {
              if (patterns[p].default) {
                botkit.middleware.heard.run(that.task.bot, message, function (err, bot, message) {
                  patterns[p].callback(message, that);
                });
                return;
              }
            }

          }
        });
      } else {
        // do nothing
      }
    };

    this.setVar = function (field, value) {
      if (!this.vars) {
        this.vars = {};
      }
      this.vars[field] = value;
    };

    this.activate = function () {
      this.task.trigger('conversationStarted', [this]);
      this.task.botkit.trigger('conversationStarted', [this.task.bot, this]);
      this.status = 'active';
    };

    /**
     * active includes both ACTIVE and ENDING
     * in order to allow the timeout end scripts to play out
     **/
    this.isActive = function () {
      return (this.status == 'active' || this.status == 'ending');
    };

    this.deactivate = function () {
      this.status = 'inactive';
    };

    this.say = function (message) {
      this.addMessage(message);
    };

    this.sayFirst = function (message) {
      if (typeof (message) == 'string') {
        message = {
          text: message,
          channel: this.source_message.channel,
        };
      } else {
        message.channel = this.source_message.channel;
      }
      this.messages.unshift(message);
    };


    this.on = function (event, cb) {
      botkit.logger.debug('Setting up a handler for', event);
      var events = event.split(/\,/g);
      for (var e in events) {
        if (!this.events[events[e]]) {
          this.events[events[e]] = [];
        }
        this.events[events[e]].push(cb);
      }
      return this;
    };

    this.trigger = function (event, data) {
      if (this.events[event]) {
        for (var e = 0; e < this.events[event].length; e++) {
          var res = this.events[event][e].apply(this, data);
          if (res === false) {
            return;
          }
        }
      } else { }
    };

    // proceed to the next message after waiting for an answer
    this.next = function () {
      this.handler = null;
    };

    this.repeat = function () {
      if (this.sent.length) {
        this.messages.push(this.sent[this.sent.length - 1]);
      } else {
        // console.log('TRIED TO REPEAT, NOTHING TO SAY');
      }
    };

    this.silentRepeat = function () {
      return;
    };

    this.addQuestion = function (message, cb, capture_options, thread) {
      if (typeof (message) == 'string') {
        message = {
          text: message,
          channel: this.source_message.channel
        };
      } else {
        message.channel = this.source_message.channel;
      }

      if (capture_options) {
        message.capture_options = capture_options;
      }

      message.handler = cb;
      this.addMessage(message, thread);
    };


    this.ask = function (message, cb, capture_options) {
      this.addQuestion(message, cb, capture_options, this.thread || 'default');
    };

    this.addMessage = function (message, thread) {
      if (!thread) {
        thread = this.thread;
      }
      if (typeof (message) == 'string') {
        message = {
          text: message,
          channel: this.source_message.channel,
        };
      } else {
        message.channel = this.source_message.channel;
      }

      if (!this.threads[thread]) {
        this.threads[thread] = [];
      }
      this.threads[thread].push(message);

      // this is the current topic, so add it here as well
      if (this.thread == thread) {
        this.messages.push(message);
      }
    };

    // how long should the bot wait while a user answers?
    this.setTimeout = function (timeout) {
      this.task.timeLimit = timeout;
    };

    // For backwards compatibility, wrap gotoThread in its previous name
    this.changeTopic = function (topic) {
      this.gotoThread(topic);
    };

    this.hasThread = function (thread) {
      return (this.threads[thread] != undefined);
    };


    this.transitionTo = function (thread, message) {

      // add a new transition thread
      // add this new message to it
      // set that message action to execute the actual transition
      // then change threads to transition thread

      var num = 1;
      while (this.hasThread('transition_' + num)) {
        num++;
      }

      var threadname = 'transition_' + num;

      if (typeof (message) == 'string') {
        message = {
          text: message,
          action: thread
        };
      } else {
        message.action = thread;
      }

      this.addMessage(message, threadname);

      this.gotoThread(threadname);

    };

    this.beforeThread = function (thread, callback) {
      if (!this.before_hooks) {
        this.before_hooks = {};
      }

      if (!this.before_hooks[thread]) {
        this.before_hooks[thread] = [];
      }
      this.before_hooks[thread].push(callback);
    };

    this.gotoThread = function (thread) {
      var that = this;
      that.next_thread = thread;
      that.processing = true;

      var makeChange = function () {
        if (!that.hasThread(that.next_thread)) {
          if (that.next_thread == 'default') {
            that.threads[that.next_thread] = [];
          } else {
            botkit.logger.debug('WARN: gotoThread() to an invalid thread!', thread);
            that.stop('unknown_thread');
            return;
          }
        }

        that.thread = that.next_thread;
        that.messages = that.threads[that.next_thread].slice();

        that.handler = null;
        that.processing = false;
      };

      if (that.before_hooks && that.before_hooks[that.next_thread]) {

        // call any beforeThread hooks in sequence
        async.eachSeries(this.before_hooks[that.next_thread], function (before_hook, next) {
          before_hook(that, next);
        }, function (err) {
          if (!err) {
            makeChange();
          }
        });

      } else {

        makeChange();

      }

    };

    this.combineMessages = function (messages) {
      if (!messages) {
        return '';
      }

      if (Array.isArray(messages) && !messages.length) {
        return '';
      }

      if (messages.length > 1) {
        var txt = [];
        var last_user = null;
        var multi_users = false;
        last_user = messages[0].user;
        for (let x = 0; x < messages.length; x++) {
          if (messages[x].user != last_user) {
            multi_users = true;
          }
        }
        last_user = '';
        for (let x = 0; x < messages.length; x++) {
          if (multi_users && messages[x].user != last_user) {
            last_user = messages[x].user;
            if (txt.length) {
              txt.push('');
            }
            txt.push('<@' + messages[x].user + '>:');
          }
          txt.push(messages[x].text);
        }
        return txt.join('\n');
      } else {
        if (messages.length) {
          return messages[0].text;
        } else {
          return messages.text;
        }
      }
    };

    this.getResponses = function () {

      var res = {};
      for (var key in this.responses) {

        res[key] = {
          question: this.responses[key].length ?
            this.responses[key][0].question : this.responses[key].question,
          key: key,
          answer: this.extractResponse(key),
        };
      }
      return res;
    };

    this.getResponsesAsArray = function () {

      var res = [];
      for (var key in this.responses) {

        res.push({
          question: this.responses[key].length ?
            this.responses[key][0].question : this.responses[key].question,
          key: key,
          answer: this.extractResponse(key),
        });
      }
      return res;
    };


    this.extractResponses = function () {

      var res = {};
      for (var key in this.responses) {
        res[key] = this.extractResponse(key);
      }
      return res;
    };

    this.extractResponse = function (key) {
      return this.combineMessages(this.responses[key]);
    };

    this.replaceAttachmentTokens = function (attachments) {

      if (attachments && attachments.length) {
        for (let a = 0; a < attachments.length; a++) {
          for (let key in attachments[a]) {
            if (typeof (attachments[a][key]) == 'string') {
              attachments[a][key] = this.replaceTokens(attachments[a][key]);
            } else {
              attachments[a][key] = this.replaceAttachmentTokens(attachments[a][key]);
            }
          }
        }
      } else {
        for (let a in attachments) {
          if (typeof (attachments[a]) == 'string') {
            attachments[a] = this.replaceTokens(attachments[a]);
          } else {
            attachments[a] = this.replaceAttachmentTokens(attachments[a]);
          }
        }
      }

      return attachments;
    };


    this.replaceTokens = function (text) {

      var vars = {
        identity: this.task.bot.identity,
        responses: this.extractResponses(),
        origin: this.task.source_message,
        vars: this.vars,
      };

      var rendered = '';

      try {
        rendered = mustache.render(text, vars);
      } catch (err) {
        botkit.logger.error('Error in message template. Mustache failed with error: ', err);
        rendered = text;
      }

      return rendered;
    };

    this.stop = function (status) {
      this.handler = null;
      this.messages = [];
      this.status = status || 'stopped';
      botkit.logger.debug('Conversation is over with status ' + this.status);
      this.task.conversationEnded(this);
    };

    // was this conversation successful?
    // return true if it was completed
    // otherwise, return false
    // false could indicate a variety of failed states:
    // manually stopped, timed out, etc
    this.successful = function () {

      // if the conversation is still going, it can't be successful yet
      if (this.isActive()) {
        return false;
      }

      if (this.status == 'completed') {
        return true;
      } else {
        return false;
      }

    };

    this.cloneMessage = function (message) {
      // clone this object so as not to modify source
      var outbound = clone(message);

      if (typeof (message.text) == 'string') {
        outbound.text = this.replaceTokens(message.text);
      } else if (message.text) {
        outbound.text = this.replaceTokens(
          message.text[Math.floor(Math.random() * message.text.length)]
        );
      }

      if (outbound.attachments) {
        outbound.attachments = this.replaceAttachmentTokens(outbound.attachments);
      }

      if (outbound.attachment) {

        // pick one variation of the message text at random
        if (outbound.attachment.payload.text && typeof (outbound.attachment.payload.text) != 'string') {
          outbound.attachment.payload.text = this.replaceTokens(
            outbound.attachment.payload.text[
            Math.floor(Math.random() * outbound.attachment.payload.text.length)
            ]
          );
        }
        outbound.attachment = this.replaceAttachmentTokens([outbound.attachment])[0];
      }

      if (this.messages.length && !message.handler) {
        outbound.continue_typing = true;
      }

      if (typeof (message.attachments) == 'function') {
        outbound.attachments = message.attachments(this);
      }

      return outbound;
    };

    this.onTimeout = function (handler) {
      if (typeof (handler) == 'function') {
        this.timeOutHandler = handler;
      } else {
        botkit.logger.debug('Invalid timeout function passed to onTimeout');
      }
    };

    this.tick = function () {
      var now = new Date();

      if (this.isActive()) {
        if (this.processing) {
          // do nothing. The bot is waiting for async process to complete.
        } else if (this.handler) {
          // check timeout!
          // how long since task started?
          var duration = (now.getTime() - this.task.startTime.getTime());
          // how long since last active?
          var lastActive = (now.getTime() - this.lastActive.getTime());

          if (this.task.timeLimit && // has a timelimit
            (duration > this.task.timeLimit) && // timelimit is up
            (lastActive > this.task.timeLimit) // nobody has typed for 60 seconds at least
          ) {
            // if timeoutHandler is set then call it, otherwise follow the normal flow
            // this will not break others code, after the update
            if (this.timeOutHandler) {
              this.timeOutHandler(this);
            } else if (this.hasThread('on_timeout')) {
              this.status = 'ending';
              this.gotoThread('on_timeout');
            } else {
              this.stop('timeout');
            }
          }
          // otherwise do nothing
        } else {
          if (this.messages.length) {

            if (this.sent.length &&
              !this.sent[this.sent.length - 1].sent
            ) {
              return;
            }

            if (this.task.bot.botkit.config.require_delivery &&
              this.sent.length &&
              !this.sent[this.sent.length - 1].delivered
            ) {
              return;
            }

            if (typeof (this.messages[0].timestamp) == 'undefined' ||
              this.messages[0].timestamp <= now.getTime()) {
              var message = this.messages.shift();
              //console.log('HANDLING NEW MESSAGE',message);
              // make sure next message is delayed appropriately
              if (this.messages.length && this.messages[0].delay) {
                this.messages[0].timestamp = now.getTime() + this.messages[0].delay;
              }
              if (message.handler) {
                //console.log(">>>>>> SET HANDLER IN TICK");
                this.handler = message.handler;
              } else {
                this.handler = null;
                //console.log(">>>>>>> CLEARING HANDLER BECAUSE NO HANDLER NEEDED");
              }
              if (message.capture_options) {
                this.capture_options = message.capture_options;
              } else {
                this.capture_options = {};
              }

              this.lastActive = new Date();

              // is there any text?
              // or an attachment? (facebook)
              // or multiple attachments (slack)
              // if (message.text || message.attachments || message.attachment) {
              if (message) {

                var outbound = this.cloneMessage(message);
                var that = this;

                outbound.sent_timestamp = new Date().getTime();

                that.sent.push(outbound);
                that.transcript.push(outbound);

                this.task.bot.reply(this.source_message, outbound, function (err, sent_message) {
                  if (err) {
                    botkit.logger.error('An error occurred while sending a message: ', err);

                    // even though an error occured, set sent to true
                    // this will allow the conversation to keep going even if one message fails
                    // TODO: make a message that fails to send _resend_ at least once
                    that.sent[that.sent.length - 1].sent = true;
                    that.sent[that.sent.length - 1].api_response = err;

                  } else {

                    that.sent[that.sent.length - 1].sent = true;
                    that.sent[that.sent.length - 1].api_response = sent_message;

                    // if sending via slack's web api, there is no further confirmation
                    // so we can mark the message delivered
                    if (that.task.bot.type == 'slack' && sent_message && sent_message.ts) {
                      that.sent[that.sent.length - 1].delivered = true;
                    }

                    that.trigger('sent', [sent_message]);

                  }
                });
              }
              if (message.action) {
                if (typeof (message.action) == 'function') {
                  message.action(this);
                } else if (message.action == 'repeat') {
                  this.repeat();
                } else if (message.action == 'wait') {
                  this.silentRepeat();
                } else if (message.action == 'stop') {
                  this.stop();
                } else if (message.action == 'timeout') {
                  this.stop('timeout');
                } else if (this.threads[message.action]) {
                  this.gotoThread(message.action);
                }
              }
            } else {
              //console.log('Waiting to send next message...');
            }

            // end immediately instad of waiting til next tick.
            // if it hasn't already been ended by a message action!
            if (this.isActive() && !this.messages.length && !this.handler && !this.processing) {
              this.stop('completed');
            }

          } else if (this.sent.length) { // sent at least 1 message
            this.stop('completed');
          }
        }
      }
    };

    botkit.logger.debug('CREATED A CONVO FOR', this.source_message.user, this.source_message.channel);
    this.gotoThread('default');
  }
/* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~