<commit_before>'use strict';
const Chat = require('./Chat');
const Conversation = require('./Conversation');
const EventEmitter = require('eventemitter3');
const express = require('express');
const bodyParser = require('body-parser');
const crypto = require('crypto');
const fetch = require('node-fetch');
const normalizeString = require('./utils/normalize-string');

class BootBot extends EventEmitter {
  constructor(options) {
    super();
    if (!options || (options && (!options.accessToken || !options.verifyToken || !options.appSecret))) {
      throw new Error('You need to specify an accessToken, verifyToken and appSecret');
    }
    this.accessToken = options.accessToken;
    this.verifyToken = options.verifyToken;
    this.appSecret = options.appSecret;
    this.broadcastEchoes = options.broadcastEchoes || false;
    this.app = express();
    this.app.use(bodyParser.json({ verify: this._verifyRequestSignature.bind(this) }));
    this._hearMap = [];
    this._conversations = [];
  }

  start(port) {
    this._initWebhook();
    this.app.set('port', port || 3000);
    this.server = this.app.listen(this.app.get('port'), () => {
      const portNum = this.app.get('port');
      console.log('BootBot running on port', portNum);
      console.log(`Facebook Webhook running on localhost:${portNum}/webhook`);
    });
  }

  close() {
    this.server.close();
  }

  sendTextMessage(recipientId, text, quickReplies, options) {
    const message = { text };
    const formattedQuickReplies = this._formatQuickReplies(quickReplies);
    if (formattedQuickReplies && formattedQuickReplies.length > 0) {
      message.quick_replies = formattedQuickReplies;
    }
    return this.sendMessage(recipientId, message, options);
  }

  sendButtonTemplate(recipientId, text, buttons, options) {
    const payload = {
      template_type: 'button',
      text
    };
    const formattedButtons = this._formatButtons(buttons);
    payload.buttons = formattedButtons;
    return this.sendTemplate(recipientId, payload, options);
  }

  sendGenericTemplate(recipientId, elements, options) {
    const payload = {
      template_type: 'generic',
      elements
    };
    options && options.imageAspectRatio && (payload.image_aspect_ratio = options.imageAspectRatio) && (delete options.imageAspectRatio);
    return this.sendTemplate(recipientId, payload, options);
  }

  sendListTemplate(recipientId, elements, buttons, options) {
    const payload = {
      template_type: 'list',
      elements
    };
    options && options.topElementStyle && (payload.top_element_style = options.topElementStyle) && (delete options.topElementStyle);
    buttons && buttons.length && (payload.buttons = this._formatButtons([buttons[0]]));
    return this.sendTemplate(recipientId, payload, options);
  }

  sendTemplate(recipientId, payload, options) {
    const message = {
      attachment: {
        type: 'template',
        payload
      }
    };
    return this.sendMessage(recipientId, message, options);
  }

  sendAttachment(recipientId, type, url, quickReplies, options) {
    const message = {
      attachment: {
        type,
        payload: { url }
      }
    };
    const formattedQuickReplies = this._formatQuickReplies(quickReplies);
    if (formattedQuickReplies && formattedQuickReplies.length > 0) {
      message.quick_replies = formattedQuickReplies;
    }
    return this.sendMessage(recipientId, message, options);
  }

  sendAction(recipientId, action, options) {
    const recipient = this._createRecipient(recipientId);
    return this.sendRequest({
      recipient,
      sender_action: action
    });
  }

  sendMessage(recipientId, message, options) {
    const recipient = this._createRecipient(recipientId);
    const onDelivery = options && options.onDelivery;
    const onRead = options && options.onRead;
    const req = () => (
      this.sendRequest({
        recipient,
        message
      }).then((json) => {
        if (typeof onDelivery === 'function') {
          this.once('delivery', onDelivery);
        }
        if (typeof onRead === 'function') {
          this.once('read', onRead);
        }
        return json;
      })
    );
    if (options && options.typing) {
      const autoTimeout = (message && message.text) ? message.text.length * 10 : 1000;
      const timeout = (typeof options.typing === 'number') ? options.typing : autoTimeout;
      return this.sendTypingIndicator(recipientId, timeout).then(req);
    }
    return req();
  }

  sendRequest(body, endpoint, method) {
    endpoint = endpoint || 'messages';
    method = method || 'POST';
    return fetch(`https://graph.facebook.com/v2.6/me/${endpoint}?access_token=${this.accessToken}`, {
      method,
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(body)
    })
    .then(res => res.json())
    .then(res => {
      if (res.error) {
        console.log('Messenger Error received. For more information about error codes, see: https://goo.gl/d76uvB');
        console.log(res.error);
      }
      return res;
    })
    .catch(err => console.log(`Error sending message: ${err}`));
  }

  sendThreadRequest(body, method) {
    console.warning(`
      sendThreadRequest: Dreprecation warning. Thread API has been replaced by the Messenger Profile API.
      Please update your code to use the sendProfileRequest() method instead.`
    );
    return this.sendRequest(body, 'thread_settings', method);
  }

  sendProfileRequest(body, method) {
    return this.sendRequest(body, 'messenger_profile', method);
  }

  sendTypingIndicator(recipientId, milliseconds) {
    const timeout = isNaN(milliseconds) ? 0 : milliseconds;
    if (milliseconds > 20000) {
      milliseconds = 20000;
      console.error('sendTypingIndicator: max milliseconds value is 20000 (20 seconds)');
    }
    return new Promise((resolve, reject) => {
      return this.sendAction(recipientId, 'typing_on').then(() => {
        setTimeout(() => this.sendAction(recipientId, 'typing_off').then((json) => resolve(json)), timeout);
      });
    });
  }

  getUserProfile(userId) {
    const url = `https://graph.facebook.com/v2.6/${userId}?fields=first_name,last_name,profile_pic,locale,timezone,gender&access_token=${this.accessToken}`;
    return fetch(url)
      .then(res => res.json())
      .catch(err => console.log(`Error getting user profile: ${err}`));
  }

  setGreetingText(text) {
    const greeting = (typeof text !== 'string') ? text : [{
      locale: 'default',
      text
    }];
    return this.sendProfileRequest({ greeting });
  }

  setGetStartedButton(action) {
    const payload = (typeof action === 'string') ? action : 'BOOTBOT_GET_STARTED';
    if (typeof action === 'function') {
      this.on(`postback:${payload}`, action);
    }
    return this.sendProfileRequest({
      get_started: {
        payload
      }
    });
  }

  deleteGetStartedButton() {
    return this.sendProfileRequest({
      fields: [
        'get_started'
      ]
    }, 'DELETE');
  }

  setPersistentMenu(buttons, disableInput) {
    if (buttons && buttons[0] && buttons[0].locale !== undefined) {
      // Received an array of locales, send it as-is.
      return this.sendProfileRequest({ persistent_menu: buttons });
    }
    // If it's not an array of locales, we'll assume is an array of buttons.
    const formattedButtons = this._formatButtons(buttons);
    return this.sendProfileRequest({
      persistent_menu: [{
        locale: 'default',
        composer_input_disabled: disableInput || false,
        call_to_actions: formattedButtons
      }]
    });
  }

  deletePersistentMenu() {
    return this.sendProfileRequest({
      fields: [
        'persistent_menu'
      ]
    }, 'DELETE');
  }

  say(recipientId, message, options) {
    if (typeof message === 'string') {
      return this.sendTextMessage(recipientId, message, [], options);
    } else if (message && message.text) {
      if (message.quickReplies && message.quickReplies.length > 0) {
        return this.sendTextMessage(recipientId, message.text, message.quickReplies, options);
      } else if (message.buttons && message.buttons.length > 0) {
        return this.sendButtonTemplate(recipientId, message.text, message.buttons, options);
      }
    } else if (message && message.attachment) {
      return this.sendAttachment(recipientId, message.attachment, message.url, message.quickReplies, options);
    }
    console.error('Invalid format for .say() message.');
  }

  hear(keywords, callback) {
    keywords = Array.isArray(keywords) ? keywords : [keywords];
    keywords.forEach(keyword => this._hearMap.push({ keyword, callback }));
    return this;
  }

  module(factory) {
    return factory.apply(this, [this]);
  }

  conversation(recipientId, factory) {
    if (!recipientId || !factory || typeof factory !== 'function') {
      return console.error(`You need to specify a recipient and a callback to start a conversation`);
    }
    const convo = new Conversation(this, recipientId);
    this._conversations.push(convo);
    convo.on('end', (endedConvo) => {
      const removeIndex = this._conversations.indexOf(endedConvo);
      this._conversations.splice(removeIndex, 1);
    });
    factory.apply(this, [convo]);
    return convo;
  }

  _formatButtons(buttons) {
    return buttons && buttons.map((button) => {
      if (typeof button === 'string') {
        return {
          type: 'postback',
          title: button,
          payload: 'BOOTBOT_BUTTON_' + normalizeString(button)
        };
      } else if (button && button.title) {
        return button;
      }
      return {};
    });
  }

  _formatQuickReplies(quickReplies) {
    return quickReplies && quickReplies.map((reply) => {
      if (typeof reply === 'string') {
        return {
          content_type: 'text',
          title: reply,
          payload: 'BOOTBOT_QR_' + normalizeString(reply)
        };
      } else if (reply && reply.title) {
        return Object.assign({
          content_type: 'text',
          payload: 'BOOTBOT_QR_' + normalizeString(reply.title)
        }, reply);
      }
      return reply;
    });
  }

  _handleEvent(type, event, data) {
    const recipient = (type === 'authentication' && !event.sender) ? { user_ref: event.optin.user_ref } : event.sender.id;
    const chat = new Chat(this, recipient);
    this.emit(type, event, chat, data);
  }

  _handleMessageEvent(event) {
    if (this._handleConversationResponse('message', event)) { return; }
    const text = event.message.text;
    const senderId = event.sender.id;
    let captured = false;
    if (!text) { return; }

    this._hearMap.forEach(hear => {
      if (typeof hear.keyword === 'string' && hear.keyword.toLowerCase() === text.toLowerCase()) {
        const res = hear.callback.apply(this, [event, new Chat(this, senderId), {
          keyword: hear.keyword,
          captured
        }]);
        captured = true;
        return res;
      } else if (hear.keyword instanceof RegExp && hear.keyword.test(text)) {
        const res = hear.callback.apply(this, [event, new Chat(this, senderId), {
          keyword: hear.keyword,
          match: text.match(hear.keyword),
          captured
        }]);
        captured = true;
        return res;
      }
    });

    this._handleEvent('message', event, { captured });
  }

  _handleAttachmentEvent(event) {
    if (this._handleConversationResponse('attachment', event)) { return; }
    this._handleEvent('attachment', event);
  }

  _handlePostbackEvent(event) {
    if (this._handleConversationResponse('postback', event)) { return; }
    const payload = event.postback.payload;
    if (payload) {
      this._handleEvent(`postback:${payload}`, event);
    }
    this._handleEvent('postback', event);
  }

  _handleQuickReplyEvent(event) {
    if (this._handleConversationResponse('quick_reply', event)) { return; }
    const payload = event.message.quick_reply && event.message.quick_reply.payload;
    if (payload) {
      this._handleEvent(`quick_reply:${payload}`, event);
    }
    this._handleEvent('quick_reply', event);
  }

  _handleConversationResponse(type, event) {
    const userId = event.sender.id;
    let captured = false;
    this._conversations.forEach(convo => {
      if (userId && userId === convo.userId && convo.isActive()) {
        captured = true;
        return convo.respond(event, { type });
      }
    });
    return captured;
  }

  _createRecipient(recipient) {
    return (typeof recipient === 'object') ? recipient : { id: recipient };
  }

  _initWebhook() {
    this.app.get('/webhook', (req, res) => {
      if (req.query['hub.mode'] === 'subscribe' && req.query['hub.verify_token'] === this.verifyToken) {
        console.log('Validation Succeded.')
        res.status(200).send(req.query['hub.challenge']);
      } else {
        console.error('Failed validation. Make sure the validation tokens match.');
        res.sendStatus(403);
      }
    });

    this.app.post('/webhook', (req, res) => {
      var data = req.body;
      if (data.object !== 'page') {
        return;
      }

      // Iterate over each entry. There may be multiple if batched.
      data.entry.forEach((entry) => {
        // Iterate over each messaging event
        entry.messaging.forEach((event) => {
          if (event.message && event.message.is_echo && !this.broadcastEchoes) {
            return;
          }
          if (event.optin) {
            this._handleEvent('authentication', event);
          } else if (event.message && event.message.text) {
            this._handleMessageEvent(event);
            if (event.message.quick_reply) {
              this._handleQuickReplyEvent(event);
            }
          } else if (event.message && event.message.attachments) {
            this._handleAttachmentEvent(event);
          } else if (event.postback) {
            this._handlePostbackEvent(event);
          } else if (event.delivery) {
            this._handleEvent('delivery', event);
          } else if (event.read) {
            this._handleEvent('read', event);
          } else if (event.account_linking) {
            this._handleEvent('account_linking', event);
          } else if (event.referral) {
            this._handleEvent('referral', event);
          } else {
            console.log('Webhook received unknown event: ', event);
          }
        });
      });

      // Must send back a 200 within 20 seconds or the request will time out.
      res.sendStatus(200);
    });
  }

  _verifyRequestSignature(req, res, buf) {
    var signature = req.headers['x-hub-signature'];
    if (!signature) {
      throw new Error('Couldn\'t validate the request signature.');
    } else {
      var elements = signature.split('=');
      var method = elements[0];
      var signatureHash = elements[1];
      var expectedHash = crypto.createHmac('sha1', this.appSecret)
        .update(buf)
        .digest('hex');

      if (signatureHash != expectedHash) {
        throw new Error("Couldn't validate the request signature.");
      }
    }
  }
}

module.exports = BootBot;
<commit_msg>added ListTemplate and GenericTemplate to say() functionality<commit_after>'use strict';
const Chat = require('./Chat');
const Conversation = require('./Conversation');
const EventEmitter = require('eventemitter3');
const express = require('express');
const bodyParser = require('body-parser');
const crypto = require('crypto');
const fetch = require('node-fetch');
const normalizeString = require('./utils/normalize-string');

class BootBot extends EventEmitter {
  constructor(options) {
    super();
    if (!options || (options && (!options.accessToken || !options.verifyToken || !options.appSecret))) {
      throw new Error('You need to specify an accessToken, verifyToken and appSecret');
    }
    this.accessToken = options.accessToken;
    this.verifyToken = options.verifyToken;
    this.appSecret = options.appSecret;
    this.broadcastEchoes = options.broadcastEchoes || false;
    this.app = express();
    this.app.use(bodyParser.json({ verify: this._verifyRequestSignature.bind(this) }));
    this._hearMap = [];
    this._conversations = [];
  }

  start(port) {
    this._initWebhook();
    this.app.set('port', port || 3000);
    this.server = this.app.listen(this.app.get('port'), () => {
      const portNum = this.app.get('port');
      console.log('BootBot running on port', portNum);
      console.log(`Facebook Webhook running on localhost:${portNum}/webhook`);
    });
  }

  close() {
    this.server.close();
  }

  sendTextMessage(recipientId, text, quickReplies, options) {
    const message = { text };
    const formattedQuickReplies = this._formatQuickReplies(quickReplies);
    if (formattedQuickReplies && formattedQuickReplies.length > 0) {
      message.quick_replies = formattedQuickReplies;
    }
    return this.sendMessage(recipientId, message, options);
  }

  sendButtonTemplate(recipientId, text, buttons, options) {
    const payload = {
      template_type: 'button',
      text
    };
    const formattedButtons = this._formatButtons(buttons);
    payload.buttons = formattedButtons;
    return this.sendTemplate(recipientId, payload, options);
  }

  sendGenericTemplate(recipientId, elements, options) {
    const payload = {
      template_type: 'generic',
      elements
    };
    options && options.imageAspectRatio && (payload.image_aspect_ratio = options.imageAspectRatio) && (delete options.imageAspectRatio);
    return this.sendTemplate(recipientId, payload, options);
  }

  sendListTemplate(recipientId, elements, buttons, options) {
    const payload = {
      template_type: 'list',
      elements
    };
    options && options.topElementStyle && (payload.top_element_style = options.topElementStyle) && (delete options.topElementStyle);
    buttons && buttons.length && (payload.buttons = this._formatButtons([buttons[0]]));
    return this.sendTemplate(recipientId, payload, options);
  }

  sendTemplate(recipientId, payload, options) {
    const message = {
      attachment: {
        type: 'template',
        payload
      }
    };
    return this.sendMessage(recipientId, message, options);
  }

  sendAttachment(recipientId, type, url, quickReplies, options) {
    const message = {
      attachment: {
        type,
        payload: { url }
      }
    };
    const formattedQuickReplies = this._formatQuickReplies(quickReplies);
    if (formattedQuickReplies && formattedQuickReplies.length > 0) {
      message.quick_replies = formattedQuickReplies;
    }
    return this.sendMessage(recipientId, message, options);
  }

  sendAction(recipientId, action, options) {
    const recipient = this._createRecipient(recipientId);
    return this.sendRequest({
      recipient,
      sender_action: action
    });
  }

  sendMessage(recipientId, message, options) {
    const recipient = this._createRecipient(recipientId);
    const onDelivery = options && options.onDelivery;
    const onRead = options && options.onRead;
    const req = () => (
      this.sendRequest({
        recipient,
        message
      }).then((json) => {
        if (typeof onDelivery === 'function') {
          this.once('delivery', onDelivery);
        }
        if (typeof onRead === 'function') {
          this.once('read', onRead);
        }
        return json;
      })
    );
    if (options && options.typing) {
      const autoTimeout = (message && message.text) ? message.text.length * 10 : 1000;
      const timeout = (typeof options.typing === 'number') ? options.typing : autoTimeout;
      return this.sendTypingIndicator(recipientId, timeout).then(req);
    }
    return req();
  }

  sendRequest(body, endpoint, method) {
    endpoint = endpoint || 'messages';
    method = method || 'POST';
    return fetch(`https://graph.facebook.com/v2.6/me/${endpoint}?access_token=${this.accessToken}`, {
      method,
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(body)
    })
    .then(res => res.json())
    .then(res => {
      if (res.error) {
        console.log('Messenger Error received. For more information about error codes, see: https://goo.gl/d76uvB');
        console.log(res.error);
      }
      return res;
    })
    .catch(err => console.log(`Error sending message: ${err}`));
  }

  sendThreadRequest(body, method) {
    console.warning(`
      sendThreadRequest: Dreprecation warning. Thread API has been replaced by the Messenger Profile API.
      Please update your code to use the sendProfileRequest() method instead.`
    );
    return this.sendRequest(body, 'thread_settings', method);
  }

  sendProfileRequest(body, method) {
    return this.sendRequest(body, 'messenger_profile', method);
  }

  sendTypingIndicator(recipientId, milliseconds) {
    const timeout = isNaN(milliseconds) ? 0 : milliseconds;
    if (milliseconds > 20000) {
      milliseconds = 20000;
      console.error('sendTypingIndicator: max milliseconds value is 20000 (20 seconds)');
    }
    return new Promise((resolve, reject) => {
      return this.sendAction(recipientId, 'typing_on').then(() => {
        setTimeout(() => this.sendAction(recipientId, 'typing_off').then((json) => resolve(json)), timeout);
      });
    });
  }

  getUserProfile(userId) {
    const url = `https://graph.facebook.com/v2.6/${userId}?fields=first_name,last_name,profile_pic,locale,timezone,gender&access_token=${this.accessToken}`;
    return fetch(url)
      .then(res => res.json())
      .catch(err => console.log(`Error getting user profile: ${err}`));
  }

  setGreetingText(text) {
    const greeting = (typeof text !== 'string') ? text : [{
      locale: 'default',
      text
    }];
    return this.sendProfileRequest({ greeting });
  }

  setGetStartedButton(action) {
    const payload = (typeof action === 'string') ? action : 'BOOTBOT_GET_STARTED';
    if (typeof action === 'function') {
      this.on(`postback:${payload}`, action);
    }
    return this.sendProfileRequest({
      get_started: {
        payload
      }
    });
  }

  deleteGetStartedButton() {
    return this.sendProfileRequest({
      fields: [
        'get_started'
      ]
    }, 'DELETE');
  }

  setPersistentMenu(buttons, disableInput) {
    if (buttons && buttons[0] && buttons[0].locale !== undefined) {
      // Received an array of locales, send it as-is.
      return this.sendProfileRequest({ persistent_menu: buttons });
    }
    // If it's not an array of locales, we'll assume is an array of buttons.
    const formattedButtons = this._formatButtons(buttons);
    return this.sendProfileRequest({
      persistent_menu: [{
        locale: 'default',
        composer_input_disabled: disableInput || false,
        call_to_actions: formattedButtons
      }]
    });
  }

  deletePersistentMenu() {
    return this.sendProfileRequest({
      fields: [
        'persistent_menu'
      ]
    }, 'DELETE');
  }

  say(recipientId, message, options) {
    if (typeof message === 'string') {
      return this.sendTextMessage(recipientId, message, [], options);
    } else if (message && message.text) {
      if (message.quickReplies && message.quickReplies.length > 0) {
        return this.sendTextMessage(recipientId, message.text, message.quickReplies, options);
      } else if (message.buttons && message.buttons.length > 0) {
        return this.sendButtonTemplate(recipientId, message.text, message.buttons, options);
      }
    } else if (message && message.attachment) {
      return this.sendAttachment(recipientId, message.attachment, message.url, message.quickReplies, options);
    }
    else if(message && message.elements && message.buttons && message.options){
      return this.sendListTemplate(recipientId, message.elements, message.buttons, message.options);
    }
    else if(message && message.cards && message.options){
      return this.sendGenericTemplate(recipientId, message.cards, message.options);
    }
    console.error('Invalid format for .say() message.');
  }

  hear(keywords, callback) {
    keywords = Array.isArray(keywords) ? keywords : [keywords];
    keywords.forEach(keyword => this._hearMap.push({ keyword, callback }));
    return this;
  }

  module(factory) {
    return factory.apply(this, [this]);
  }

  conversation(recipientId, factory) {
    if (!recipientId || !factory || typeof factory !== 'function') {
      return console.error(`You need to specify a recipient and a callback to start a conversation`);
    }
    const convo = new Conversation(this, recipientId);
    this._conversations.push(convo);
    convo.on('end', (endedConvo) => {
      const removeIndex = this._conversations.indexOf(endedConvo);
      this._conversations.splice(removeIndex, 1);
    });
    factory.apply(this, [convo]);
    return convo;
  }

  _formatButtons(buttons) {
    return buttons && buttons.map((button) => {
      if (typeof button === 'string') {
        return {
          type: 'postback',
          title: button,
          payload: 'BOOTBOT_BUTTON_' + normalizeString(button)
        };
      } else if (button && button.title) {
        return button;
      }
      return {};
    });
  }

  _formatQuickReplies(quickReplies) {
    return quickReplies && quickReplies.map((reply) => {
      if (typeof reply === 'string') {
        return {
          content_type: 'text',
          title: reply,
          payload: 'BOOTBOT_QR_' + normalizeString(reply)
        };
      } else if (reply && reply.title) {
        return Object.assign({
          content_type: 'text',
          payload: 'BOOTBOT_QR_' + normalizeString(reply.title)
        }, reply);
      }
      return reply;
    });
  }

  _handleEvent(type, event, data) {
    const recipient = (type === 'authentication' && !event.sender) ? { user_ref: event.optin.user_ref } : event.sender.id;
    const chat = new Chat(this, recipient);
    this.emit(type, event, chat, data);
  }

  _handleMessageEvent(event) {
    if (this._handleConversationResponse('message', event)) { return; }
    const text = event.message.text;
    const senderId = event.sender.id;
    let captured = false;
    if (!text) { return; }

    this._hearMap.forEach(hear => {
      if (typeof hear.keyword === 'string' && hear.keyword.toLowerCase() === text.toLowerCase()) {
        const res = hear.callback.apply(this, [event, new Chat(this, senderId), {
          keyword: hear.keyword,
          captured
        }]);
        captured = true;
        return res;
      } else if (hear.keyword instanceof RegExp && hear.keyword.test(text)) {
        const res = hear.callback.apply(this, [event, new Chat(this, senderId), {
          keyword: hear.keyword,
          match: text.match(hear.keyword),
          captured
        }]);
        captured = true;
        return res;
      }
    });

    this._handleEvent('message', event, { captured });
  }

  _handleAttachmentEvent(event) {
    if (this._handleConversationResponse('attachment', event)) { return; }
    this._handleEvent('attachment', event);
  }

  _handlePostbackEvent(event) {
    if (this._handleConversationResponse('postback', event)) { return; }
    const payload = event.postback.payload;
    if (payload) {
      this._handleEvent(`postback:${payload}`, event);
    }
    this._handleEvent('postback', event);
  }

  _handleQuickReplyEvent(event) {
    if (this._handleConversationResponse('quick_reply', event)) { return; }
    const payload = event.message.quick_reply && event.message.quick_reply.payload;
    if (payload) {
      this._handleEvent(`quick_reply:${payload}`, event);
    }
    this._handleEvent('quick_reply', event);
  }

  _handleConversationResponse(type, event) {
    const userId = event.sender.id;
    let captured = false;
    this._conversations.forEach(convo => {
      if (userId && userId === convo.userId && convo.isActive()) {
        captured = true;
        return convo.respond(event, { type });
      }
    });
    return captured;
  }

  _createRecipient(recipient) {
    return (typeof recipient === 'object') ? recipient : { id: recipient };
  }

  _initWebhook() {
    this.app.get('/webhook', (req, res) => {
      if (req.query['hub.mode'] === 'subscribe' && req.query['hub.verify_token'] === this.verifyToken) {
        console.log('Validation Succeded.')
        res.status(200).send(req.query['hub.challenge']);
      } else {
        console.error('Failed validation. Make sure the validation tokens match.');
        res.sendStatus(403);
      }
    });

    this.app.post('/webhook', (req, res) => {
      var data = req.body;
      if (data.object !== 'page') {
        return;
      }

      // Iterate over each entry. There may be multiple if batched.
      data.entry.forEach((entry) => {
        // Iterate over each messaging event
        entry.messaging.forEach((event) => {
          if (event.message && event.message.is_echo && !this.broadcastEchoes) {
            return;
          }
          if (event.optin) {
            this._handleEvent('authentication', event);
          } else if (event.message && event.message.text) {
            this._handleMessageEvent(event);
            if (event.message.quick_reply) {
              this._handleQuickReplyEvent(event);
            }
          } else if (event.message && event.message.attachments) {
            this._handleAttachmentEvent(event);
          } else if (event.postback) {
            this._handlePostbackEvent(event);
          } else if (event.delivery) {
            this._handleEvent('delivery', event);
          } else if (event.read) {
            this._handleEvent('read', event);
          } else if (event.account_linking) {
            this._handleEvent('account_linking', event);
          } else if (event.referral) {
            this._handleEvent('referral', event);
          } else {
            console.log('Webhook received unknown event: ', event);
          }
        });
      });

      // Must send back a 200 within 20 seconds or the request will time out.
      res.sendStatus(200);
    });
  }

  _verifyRequestSignature(req, res, buf) {
    var signature = req.headers['x-hub-signature'];
    if (!signature) {
      throw new Error('Couldn\'t validate the request signature.');
    } else {
      var elements = signature.split('=');
      var method = elements[0];
      var signatureHash = elements[1];
      var expectedHash = crypto.createHmac('sha1', this.appSecret)
        .update(buf)
        .digest('hex');

      if (signatureHash != expectedHash) {
        throw new Error("Couldn't validate the request signature.");
      }
    }
  }
}

module.exports = BootBot;
<|endoftext|>


<commit_before>const { CRON: { allowedNum, partRegex, day, predefined, tokens, tokensRegex } } = require('./constants');

/**
 * Handles Cron strings and generates dates based on the cron string provided.
 * @see https://en.wikipedia.org/wiki/Cron
 */
class Cron {

	/**
	 * @since 0.5.0
	 * @param {string} cron The cron pattern to use
	 */
	constructor(cron) {
		this.cron = cron.toLowerCase();
		this.normalized = this.constructor._normalize(this.cron);
		[this.minutes, this.hours, this.days, this.months, this.dows] = this.constructor._parseString(this.normalized);
	}

	/**
	 * Get the next date that matches with the current pattern
	 * @since 0.5.0
	 * @param {Date} [zDay=new Date()] The Date instance to compare with
	 * @param {boolean} [origin=true] Whether this next call is origin
	 * @returns {Date}
	 */
	next(zDay = new Date(), origin = true) {
		if (!this.days.includes(zDay.getUTCDate()) || !this.months.includes(zDay.getUTCMonth() + 1) || !this.dows.includes(zDay.getUTCDay())) return this.next(new Date(zDay.getTime() + day), false);
		if (!origin) return new Date(Date.UTC(zDay.getUTCFullYear(), zDay.getUTCMonth(), zDay.getUTCDate(), this.hours[0], this.minutes[0]));

		const now = new Date(zDay.getTime() + 60000);

		for (const hour of this.hours) {
			if (hour < now.getUTCHours()) continue;
			for (const minute of this.minutes) {
				if (minute < now.getUTCMinutes() && hour === now.getUTCHours()) continue;
				return new Date(Date.UTC(zDay.getUTCFullYear(), zDay.getUTCMonth(), zDay.getUTCDate(), hour, minute));
			}
		}

		return this.next(new Date(zDay.getTime() + day), false);
	}

	/**
	 * Normalize the pattern
	 * @since 0.5.0
	 * @param {string} cron The pattern to normalize
	 * @returns {string}
	 * @private
	 */
	static _normalize(cron) {
		if (cron in predefined) return predefined[cron];
		const now = new Date();
		cron = cron.split(' ').map((val, i) => {
			if (val === 'h') return Math.floor(Math.random() * (allowedNum[i][1] + 1));
			if (val === '?') {
				switch (i) {
					case 0: return now.getUTCMinutes();
					case 1: return now.getUTCHours();
					case 2: return now.getUTCDate();
					case 3: return now.getUTCMonth();
					case 4: return now.getUTCDay();
				}
			}
			return val;
		}).join(' ');
		return cron.replace(tokensRegex, match => tokens[match]);
	}

	/**
	 * Parse the pattern
	 * @since 0.5.0
	 * @param {string} cron The pattern to parse
	 * @returns {Array<number[]>}
	 * @private
	 */
	static _parseString(cron) {
		const parts = cron.split(' ');
		if (parts.length !== 5) throw new Error('Invalid Cron Provided');
		return parts.map(Cron._parsePart);
	}

	/**
	 * Parse the current part
	 * @since 0.5.0
	 * @param {string} cronPart The part of the pattern to parse
	 * @param {number} id The id that identifies the current part
	 * @returns {number[]}
	 * @private
	 */
	static _parsePart(cronPart, id) {
		if (cronPart.includes(',')) {
			const res = [];
			for (const part of cronPart.split(',')) res.push(...Cron._parsePart(part, id));
			return [...new Set(res)].sort((a, b) => a - b);
		}

		// eslint-disable-next-line prefer-const
		let [, wild, min, max, step] = partRegex.exec(cronPart);

		if (wild) [min, max] = allowedNum[id];
		else if (!max && !step) return [parseInt(min)];
		return Cron._range(...[parseInt(min), parseInt(max) || allowedNum[id][1]].sort((a, b) => a - b), parseInt(step) || 1);
	}

	/**
	 * Get an array of numbers with the selected range
	 * @since 0.5.0
	 * @param {number} min The minimum value
	 * @param {number} max The maximum value
	 * @param {number} step The step value
	 * @returns {number[]}
	 * @private
	 */
	static _range(min, max, step) {
		return new Array(Math.floor((max - min) / step) + 1).fill(0).map((val, i) => min + (i * step));
	}

}

module.exports = Cron;
<commit_msg>short curcuit the other way<commit_after>const { CRON: { allowedNum, partRegex, day, predefined, tokens, tokensRegex } } = require('./constants');

/**
 * Handles Cron strings and generates dates based on the cron string provided.
 * @see https://en.wikipedia.org/wiki/Cron
 */
class Cron {

	/**
	 * @since 0.5.0
	 * @param {string} cron The cron pattern to use
	 */
	constructor(cron) {
		this.cron = cron.toLowerCase();
		this.normalized = this.constructor._normalize(this.cron);
		[this.minutes, this.hours, this.days, this.months, this.dows] = this.constructor._parseString(this.normalized);
	}

	/**
	 * Get the next date that matches with the current pattern
	 * @since 0.5.0
	 * @param {Date} [zDay=new Date()] The Date instance to compare with
	 * @param {boolean} [origin=true] Whether this next call is origin
	 * @returns {Date}
	 */
	next(zDay = new Date(), origin = true) {
		if (!this.days.includes(zDay.getUTCDate()) || !this.months.includes(zDay.getUTCMonth() + 1) || !this.dows.includes(zDay.getUTCDay())) return this.next(new Date(zDay.getTime() + day), false);
		if (!origin) return new Date(Date.UTC(zDay.getUTCFullYear(), zDay.getUTCMonth(), zDay.getUTCDate(), this.hours[0], this.minutes[0]));

		const now = new Date(zDay.getTime() + 60000);

		for (const hour of this.hours) {
			if (hour < now.getUTCHours()) continue;
			for (const minute of this.minutes) {
				if (hour === now.getUTCHours() && minute < now.getUTCMinutes()) continue;
				return new Date(Date.UTC(zDay.getUTCFullYear(), zDay.getUTCMonth(), zDay.getUTCDate(), hour, minute));
			}
		}

		return this.next(new Date(zDay.getTime() + day), false);
	}

	/**
	 * Normalize the pattern
	 * @since 0.5.0
	 * @param {string} cron The pattern to normalize
	 * @returns {string}
	 * @private
	 */
	static _normalize(cron) {
		if (cron in predefined) return predefined[cron];
		const now = new Date();
		cron = cron.split(' ').map((val, i) => {
			if (val === 'h') return Math.floor(Math.random() * (allowedNum[i][1] + 1));
			if (val === '?') {
				switch (i) {
					case 0: return now.getUTCMinutes();
					case 1: return now.getUTCHours();
					case 2: return now.getUTCDate();
					case 3: return now.getUTCMonth();
					case 4: return now.getUTCDay();
				}
			}
			return val;
		}).join(' ');
		return cron.replace(tokensRegex, match => tokens[match]);
	}

	/**
	 * Parse the pattern
	 * @since 0.5.0
	 * @param {string} cron The pattern to parse
	 * @returns {Array<number[]>}
	 * @private
	 */
	static _parseString(cron) {
		const parts = cron.split(' ');
		if (parts.length !== 5) throw new Error('Invalid Cron Provided');
		return parts.map(Cron._parsePart);
	}

	/**
	 * Parse the current part
	 * @since 0.5.0
	 * @param {string} cronPart The part of the pattern to parse
	 * @param {number} id The id that identifies the current part
	 * @returns {number[]}
	 * @private
	 */
	static _parsePart(cronPart, id) {
		if (cronPart.includes(',')) {
			const res = [];
			for (const part of cronPart.split(',')) res.push(...Cron._parsePart(part, id));
			return [...new Set(res)].sort((a, b) => a - b);
		}

		// eslint-disable-next-line prefer-const
		let [, wild, min, max, step] = partRegex.exec(cronPart);

		if (wild) [min, max] = allowedNum[id];
		else if (!max && !step) return [parseInt(min)];
		return Cron._range(...[parseInt(min), parseInt(max) || allowedNum[id][1]].sort((a, b) => a - b), parseInt(step) || 1);
	}

	/**
	 * Get an array of numbers with the selected range
	 * @since 0.5.0
	 * @param {number} min The minimum value
	 * @param {number} max The maximum value
	 * @param {number} step The step value
	 * @returns {number[]}
	 * @private
	 */
	static _range(min, max, step) {
		return new Array(Math.floor((max - min) / step) + 1).fill(0).map((val, i) => min + (i * step));
	}

}

module.exports = Cron;
<|endoftext|>


<commit_before>go.app = function() {
    var vumigo = require('vumigo_v02');
    var _ = require('lodash');
    var Q = require('q');
    var App = vumigo.App;
    var Choice = vumigo.states.Choice;
    var ChoiceState = vumigo.states.ChoiceState;
    var EndState = vumigo.states.EndState;
    var FreeText = vumigo.states.FreeText;

    var GoNDOH = App.extend(function(self) {
        App.call(self, 'states_start');
        var $ = self.$;

        self.init = function() {
            self.env = self.im.config.env;
            self.metric_prefix = [self.env, self.im.config.name].join('.');
            self.store_name = [self.env, self.im.config.name].join('.');

            self.im.on('session:new', function(e) {
                self.contact.extra.ussd_sessions = go.utils.incr_user_extra(
                    self.contact.extra.ussd_sessions, 1);
                self.contact.extra.metric_sum_sessions = go.utils.incr_user_extra(self.contact.extra.metric_sum_sessions, 1);

                return Q.all([
                    self.im.contacts.save(self.contact),
                    self.im.metrics.fire.inc([self.env, 'sum.sessions'].join('.'), 1),
                    self.fire_incomplete(e.im.state.name, -1)
                ]);
            });

            self.im.on('session:close', function(e) {
                return Q.all([
                    self.fire_incomplete(e.im.state.name, 1),
                    self.dial_back(e)
                ]);
            });

            self.im.user.on('user:new', function(e) {
                return Q.all([
                    go.utils.fire_users_metrics(self.im, self.store_name, self.env, self.metric_prefix),
                    // TODO re-evaluate the use of this metric
                    // self.fire_incomplete('states_start', 1)
                ]);
            });

            self.im.on('state:enter', function(e) {
                self.contact.extra.last_stage = e.state.name;
                return self.im.contacts.save(self.contact);
            });

            return self.im.contacts
                .for_user()
                .then(function(user_contact) {
                   self.contact = user_contact;
                });
        };

        self.should_send_dialback = function(e) {
            return e.user_terminated
                && !go.utils.is_true(self.contact.extra.redial_sms_sent);
        };

        self.send_dialback = function() {
            return self.im.outbound
                .send_to_user({
                    endpoint: 'sms',
                    content: self.get_finish_reg_sms()
                })
                .then(function() {
                    self.contact.extra.redial_sms_sent = 'true';
                    return self.im.contacts.save(self.contact);
                });
        };

        self.dial_back = function(e) {
            if (!self.should_send_dialback(e)) { return; }
            return self.send_dialback();
        };

        self.get_finish_reg_sms = function() {
            return $("Please dial back in to {{ USSD_number }} to complete the pregnancy registration.")
                .context({
                    USSD_number: self.im.config.channel
                });
        };

        self.fire_incomplete = function(name, val) {
            var ignore_states = ['states_end_success', 'states_end_not_pregnant'];
            if (!_.contains(ignore_states, name)) {
                return self.im.metrics.fire.inc(([self.metric_prefix, name, "no_incomplete"].join('.')), {amount: val});
            }
        };

        // mike: my thinking for the is_registered === 'false' part was that we could, for this line,
        //      suppress the standard vumi behaviour of going to the last state on timeouts, and
        //      default to states_start instead.  From states_start it then pops into the last state
        //      if they were busy with registration.
        self.states.add('states_start', function() {
            if (_.isUndefined(self.contact.extra.is_registered)) {
                return self.states.create('states_language');
            } else if (self.contact.extra.is_registered === 'false') {
                return self.states.create(self.contact.extra.last_stage);
            } else if (self.contact.extra.is_registered_by === 'chw') {
                return self.states.create('states_registered_chw');
            } else if (self.contact.extra.is_registered_by === 'clinic') {
                return self.states.create('states_registered_clinic');
            }
        });

        self.states.add('states_language', function(name) {
            return new ChoiceState(name, {
                question: $('Welcome to The Department of Health\'s ' +
                    'MomConnect programme. Please select your preferred ' +
                    'language:'),

                choices: [
                    new Choice('en', $('English')),
                    new Choice('af', $('Afrikaans')),
                    new Choice('zu', $('Zulu')),
                    new Choice('xh', $('Xhosa')),
                    new Choice('so', $('Sotho'))
                ],

                next: function(choice) {
                    self.contact.extra.language_choice = choice.value;
                    return self.im.groups.get(choice.value)
                        .then(function(group) {
                            self.contact.groups.push(group.key);
                            return self.im.user
                                .set_lang(choice.value)
                                .then(function() {
                                    if (_.isUndefined(self.contact.extra.is_registered)) {
                                        return Q.all([
                                            go.utils.incr_kv(self.im, [self.store_name, 'no_incomplete_registrations'].join('.')),
                                            go.utils.adjust_percentage_registrations(self.im, self.metric_prefix)
                                        ]);
                                    }
                                })
                                .then(function() {
                                    self.contact.extra.is_registered = 'false';
                                    return self.im.contacts.save(self.contact);
                                })
                                .then(function() {
                                    return 'states_suspect_pregnancy';
                                });
                        });
                }
            });
        });

        self.states.add('states_suspect_pregnancy', function(name) {
            return new ChoiceState(name, {
                question: $('MomConnect sends free support SMSs to ' +
                    'pregnant mothers. Are you or do you suspect that you ' +
                    'are pregnant?'),

                choices: [
                    new Choice('yes', $('Yes')),
                    new Choice('no', $('No'))
                ],

                next: function(choice) {
                    self.contact.extra.suspect_pregnancy = choice.value;

                    return self.im.contacts
                        .save(self.contact)
                        .then(function() {
                            return {
                                yes: 'states_id_type',
                                no: 'states_end_not_pregnant'
                            } [choice.value];
                        });
                }
            });
        });

        self.states.add('states_end_not_pregnant', function(name) {
            return new EndState(name, {
                text: $('We are sorry but this service is only for ' +
                    'pregnant mothers. If you have other health concerns ' +
                    'please visit your nearest clinic.'),
                next: 'states_start'
            });
        });

        self.states.add('states_id_type', function(name) {
            return new ChoiceState(name, {
                question: $('We need some info to message you. This ' +
                    'is private and will only be used to help you at a ' +
                    'clinic. What kind of ID do you have?'),

                choices: [
                    new Choice('sa_id', $('SA ID')),
                    new Choice('passport', $('Passport')),
                    new Choice('none', $('None'))
                ],

                next: function(choice) {
                    self.contact.extra.id_type = choice.value;

                    return self.im.contacts
                        .save(self.contact)
                        .then(function() {
                            return {
                                sa_id: 'states_sa_id',
                                passport: 'states_passport_origin',
                                none: 'states_birth_year'
                            } [choice.value];
                        });
                }
            });
        });

        self.states.add('states_sa_id', function(name, opts) {
            var error = $('Sorry, your ID number did not validate. ' +
                          'Please reenter your SA ID number:');

            var question = $('Please enter your SA ID number:');

            return new FreeText(name, {
                question: question,

                check: function(content) {
                    if (!go.utils.validate_id_sa(content)) {
                        return error;
                    }
                },

                next: function(content) {
                    self.contact.extra.sa_id = content;

                    var id_date_of_birth = go.utils.extract_id_dob(content);
                    self.contact.extra.birth_year = id_date_of_birth.slice(0,4);
                    self.contact.extra.birth_month = id_date_of_birth.slice(5,7);
                    self.contact.extra.birth_day = id_date_of_birth.slice(8,10);
                    self.contact.extra.dob = id_date_of_birth;

                    return self.im.contacts
                        .save(self.contact)
                        .then(function() {
                            return {
                                name: 'states_end_success'
                            };
                        });
                }
            });
        });

        self.states.add('states_passport_origin', function(name) {
            return new ChoiceState(name, {
                question: $('What is the country of origin of the passport?'),

                choices: [
                    new Choice('zw', $('Zimbabwe')),
                    new Choice('mz', $('Mozambique')),
                    new Choice('mw', $('Malawi')),
                    new Choice('ng', $('Nigeria')),
                    new Choice('cd', $('DRC')),
                    new Choice('so', $('Somalia')),
                    new Choice('other', $('Other'))
                ],

                next: function(choice) {
                    self.contact.extra.passport_origin = choice.value;

                    return self.im.contacts
                        .save(self.contact)
                        .then(function() {
                            return {
                                name: 'states_passport_no'
                            };
                        });
                }
            });
        });

        self.states.add('states_passport_no', function(name) {
            var error = $('There was an error in your entry. Please ' +
                        'carefully enter your passport number again.');
            var question = $('Please enter your Passport number:');

            return new FreeText(name, {
                question: question,

                check: function(content) {
                    if (!go.utils.is_alpha_numeric_only(content) || content.length <= 4) {
                        return error;
                    }
                },

                next: function(content) {
                    self.contact.extra.passport_no = content;

                    return self.im.contacts
                        .save(self.contact)
                        .then(function() {
                            return {
                                name: 'states_end_success'
                            };
                        });
                }
            });
        });

        self.states.add('states_birth_year', function(name, opts) {
            var error = $('There was an error in your entry. Please ' +
                        'carefully enter your year of birth again (for ' +
                        'example: 2001)');

            var question = $('Since you don\'t have an ID or passport, ' +
                            'please enter the year that you were born (for ' +
                            'example: 1981)');

            return new FreeText(name, {
                question: question,

                check: function(content) {
                    if (!go.utils.check_number_in_range(content, 1900, go.utils.get_today(self.im.config).getFullYear())) {
                        return error;
                    }
                },

                next: function(content) {
                    self.contact.extra.birth_year = content;

                    return self.im.contacts
                        .save(self.contact)
                        .then(function() {
                            return {
                                name: 'states_birth_month'
                            };
                        });
                }
            });
        });

        self.states.add('states_birth_month', function(name) {
            return new ChoiceState(name, {
                question: $('Please enter the month that you were born.'),

                choices: go.utils.make_month_choices($, 0, 12),

                next: function(choice) {
                    self.contact.extra.birth_month = choice.value;

                    return self.im.contacts
                        .save(self.contact)
                        .then(function() {
                            return {
                                name: 'states_birth_day'
                            };
                        });
                }
            });
        });

        self.states.add('states_birth_day', function(name, opts) {
            var error = $('There was an error in your entry. Please ' +
                        'carefully enter your day of birth again (for ' +
                        'example: 8)');

            var question = $('Please enter the day that you were born ' +
                    '(for example: 14).');

            return new FreeText(name, {
                question: question,

                check: function(content) {
                    if (!go.utils.check_number_in_range(content, 1, 31)) {
                        return error;
                    }
                },

                next: function(content) {
                    if (content.length === 1) {
                        content = '0' + content;
                    }
                    self.contact.extra.birth_day = content;
                    self.contact.extra.dob = (self.im.user.answers.states_birth_year +
                        '-' + self.im.user.answers.states_birth_month +
                        '-' + content);
                    self.contact.extra.is_registered = 'true';
                    self.contact.extra.is_registered_by = 'personal';
                    self.contact.extra.metric_sessions_to_register = self.contact.extra.ussd_sessions;

                    return self.im.contacts
                        .save(self.contact)
                        .then(function() {
                            return Q.all([
                                self.im.metrics.fire.avg((self.metric_prefix + ".avg.sessions_to_register"),
                                    parseInt(self.contact.extra.ussd_sessions, 10)),
                                go.utils.incr_kv(self.im, [self.store_name, 'no_complete_registrations'].join('.')),
                                go.utils.decr_kv(self.im, [self.store_name, 'no_incomplete_registrations'].join('.')),
                                go.utils.adjust_percentage_registrations(self.im, self.metric_prefix)
                            ]);
                        })
                        .then(function() {
                            self.contact.extra.ussd_sessions = '0';
                            return self.im.contacts.save(self.contact);
                        })
                        .then(function() {
                            return {
                                name: 'states_end_success'
                            };
                        });
                }
            });
        });

        self.states.add('states_end_success', function(name) {
            return new EndState(name, {
                text: $('Thank you for subscribing to MomConnect. ' +
                        'You will now receive free messages about ' +
                        'MomConnect. Visit your nearest clinic to get ' +
                        'the full set of messages.'),

                next: 'states_start',

                events: {
                    'state:enter': function() {
                        return Q.all([
                            go.utils.jembi_send_json(self.contact, self.contact, 'subscription', self.im, self.metric_prefix),
                            go.utils.subscription_send_doc(self.contact, self.im, self.metric_prefix),
                            self.im.outbound.send_to_user({
                                endpoint: 'sms',
                                content: "Congratulations on your pregnancy. You will now get free SMSs about MomConnect. " +
                                         "You can register for the full set of FREE helpful messages at a clinic."
                            })
                        ]);
                    }
                }
            });
        });

        self.states.add('states_error', function(name) {
            return new EndState(name, {
              text: 'Sorry, something went wrong when saving the data. Please try again.',
              next: 'states_start'
            });
        });

    });

    return {
        GoNDOH: GoNDOH
    };
}();
<commit_msg>expand states_start options<commit_after>go.app = function() {
    var vumigo = require('vumigo_v02');
    var _ = require('lodash');
    var Q = require('q');
    var App = vumigo.App;
    var Choice = vumigo.states.Choice;
    var ChoiceState = vumigo.states.ChoiceState;
    var EndState = vumigo.states.EndState;
    var FreeText = vumigo.states.FreeText;

    var GoNDOH = App.extend(function(self) {
        App.call(self, 'states_start');
        var $ = self.$;

        self.init = function() {
            self.env = self.im.config.env;
            self.metric_prefix = [self.env, self.im.config.name].join('.');
            self.store_name = [self.env, self.im.config.name].join('.');

            self.im.on('session:new', function(e) {
                self.contact.extra.ussd_sessions = go.utils.incr_user_extra(
                    self.contact.extra.ussd_sessions, 1);
                self.contact.extra.metric_sum_sessions = go.utils.incr_user_extra(self.contact.extra.metric_sum_sessions, 1);

                return Q.all([
                    self.im.contacts.save(self.contact),
                    self.im.metrics.fire.inc([self.env, 'sum.sessions'].join('.'), 1),
                    self.fire_incomplete(e.im.state.name, -1)
                ]);
            });

            self.im.on('session:close', function(e) {
                return Q.all([
                    self.fire_incomplete(e.im.state.name, 1),
                    self.dial_back(e)
                ]);
            });

            self.im.user.on('user:new', function(e) {
                return Q.all([
                    go.utils.fire_users_metrics(self.im, self.store_name, self.env, self.metric_prefix),
                    // TODO re-evaluate the use of this metric
                    // self.fire_incomplete('states_start', 1)
                ]);
            });

            self.im.on('state:enter', function(e) {
                self.contact.extra.last_stage = e.state.name;
                return self.im.contacts.save(self.contact);
            });

            return self.im.contacts
                .for_user()
                .then(function(user_contact) {
                   self.contact = user_contact;
                });
        };

        self.should_send_dialback = function(e) {
            return e.user_terminated
                && !go.utils.is_true(self.contact.extra.redial_sms_sent);
        };

        self.send_dialback = function() {
            return self.im.outbound
                .send_to_user({
                    endpoint: 'sms',
                    content: self.get_finish_reg_sms()
                })
                .then(function() {
                    self.contact.extra.redial_sms_sent = 'true';
                    return self.im.contacts.save(self.contact);
                });
        };

        self.dial_back = function(e) {
            if (!self.should_send_dialback(e)) { return; }
            return self.send_dialback();
        };

        self.get_finish_reg_sms = function() {
            return $("Please dial back in to {{ USSD_number }} to complete the pregnancy registration.")
                .context({
                    USSD_number: self.im.config.channel
                });
        };

        self.fire_incomplete = function(name, val) {
            var ignore_states = ['states_end_success', 'states_end_not_pregnant'];
            if (!_.contains(ignore_states, name)) {
                return self.im.metrics.fire.inc(([self.metric_prefix, name, "no_incomplete"].join('.')), {amount: val});
            }
        };

        self.states.add('states_start', function() {
            if (_.isUndefined(self.contact.extra.is_registered)) {
                // hasn't started registration on any line
                return self.states.create('states_language');
            } else if (self.contact.extra.is_registered_by === 'clinic') {
                // registered on clinic line
                return self.states.create('states_registered_full');
            } else {
                // registered on chw / public lines or partially registered on any line
                // mike: discuss partial registration going to ..._not_full
                return self.states.create('states_registered_not_full');
            }
        });

        self.states.add('states_language', function(name) {
            return new ChoiceState(name, {
                question: $('Welcome to The Department of Health\'s ' +
                    'MomConnect programme. Please select your preferred ' +
                    'language:'),

                choices: [
                    new Choice('en', $('English')),
                    new Choice('af', $('Afrikaans')),
                    new Choice('zu', $('Zulu')),
                    new Choice('xh', $('Xhosa')),
                    new Choice('so', $('Sotho'))
                ],

                next: function(choice) {
                    self.contact.extra.language_choice = choice.value;
                    return self.im.groups.get(choice.value)
                        .then(function(group) {
                            self.contact.groups.push(group.key);
                            return self.im.user
                                .set_lang(choice.value)
                                .then(function() {
                                    if (_.isUndefined(self.contact.extra.is_registered)) {
                                        return Q.all([
                                            go.utils.incr_kv(self.im, [self.store_name, 'no_incomplete_registrations'].join('.')),
                                            go.utils.adjust_percentage_registrations(self.im, self.metric_prefix)
                                        ]);
                                    }
                                })
                                .then(function() {
                                    self.contact.extra.is_registered = 'false';
                                    return self.im.contacts.save(self.contact);
                                })
                                .then(function() {
                                    return 'states_suspect_pregnancy';
                                });
                        });
                }
            });
        });

        self.states.add('states_suspect_pregnancy', function(name) {
            return new ChoiceState(name, {
                question: $('MomConnect sends free support SMSs to ' +
                    'pregnant mothers. Are you or do you suspect that you ' +
                    'are pregnant?'),

                choices: [
                    new Choice('yes', $('Yes')),
                    new Choice('no', $('No'))
                ],

                next: function(choice) {
                    self.contact.extra.suspect_pregnancy = choice.value;

                    return self.im.contacts
                        .save(self.contact)
                        .then(function() {
                            return {
                                yes: 'states_id_type',
                                no: 'states_end_not_pregnant'
                            } [choice.value];
                        });
                }
            });
        });

        self.states.add('states_end_not_pregnant', function(name) {
            return new EndState(name, {
                text: $('We are sorry but this service is only for ' +
                    'pregnant mothers. If you have other health concerns ' +
                    'please visit your nearest clinic.'),
                next: 'states_start'
            });
        });

        self.states.add('states_id_type', function(name) {
            return new ChoiceState(name, {
                question: $('We need some info to message you. This ' +
                    'is private and will only be used to help you at a ' +
                    'clinic. What kind of ID do you have?'),

                choices: [
                    new Choice('sa_id', $('SA ID')),
                    new Choice('passport', $('Passport')),
                    new Choice('none', $('None'))
                ],

                next: function(choice) {
                    self.contact.extra.id_type = choice.value;

                    return self.im.contacts
                        .save(self.contact)
                        .then(function() {
                            return {
                                sa_id: 'states_sa_id',
                                passport: 'states_passport_origin',
                                none: 'states_birth_year'
                            } [choice.value];
                        });
                }
            });
        });

        self.states.add('states_sa_id', function(name, opts) {
            var error = $('Sorry, your ID number did not validate. ' +
                          'Please reenter your SA ID number:');

            var question = $('Please enter your SA ID number:');

            return new FreeText(name, {
                question: question,

                check: function(content) {
                    if (!go.utils.validate_id_sa(content)) {
                        return error;
                    }
                },

                next: function(content) {
                    self.contact.extra.sa_id = content;

                    var id_date_of_birth = go.utils.extract_id_dob(content);
                    self.contact.extra.birth_year = id_date_of_birth.slice(0,4);
                    self.contact.extra.birth_month = id_date_of_birth.slice(5,7);
                    self.contact.extra.birth_day = id_date_of_birth.slice(8,10);
                    self.contact.extra.dob = id_date_of_birth;

                    return self.im.contacts
                        .save(self.contact)
                        .then(function() {
                            return {
                                name: 'states_end_success'
                            };
                        });
                }
            });
        });

        self.states.add('states_passport_origin', function(name) {
            return new ChoiceState(name, {
                question: $('What is the country of origin of the passport?'),

                choices: [
                    new Choice('zw', $('Zimbabwe')),
                    new Choice('mz', $('Mozambique')),
                    new Choice('mw', $('Malawi')),
                    new Choice('ng', $('Nigeria')),
                    new Choice('cd', $('DRC')),
                    new Choice('so', $('Somalia')),
                    new Choice('other', $('Other'))
                ],

                next: function(choice) {
                    self.contact.extra.passport_origin = choice.value;

                    return self.im.contacts
                        .save(self.contact)
                        .then(function() {
                            return {
                                name: 'states_passport_no'
                            };
                        });
                }
            });
        });

        self.states.add('states_passport_no', function(name) {
            var error = $('There was an error in your entry. Please ' +
                        'carefully enter your passport number again.');
            var question = $('Please enter your Passport number:');

            return new FreeText(name, {
                question: question,

                check: function(content) {
                    if (!go.utils.is_alpha_numeric_only(content) || content.length <= 4) {
                        return error;
                    }
                },

                next: function(content) {
                    self.contact.extra.passport_no = content;

                    return self.im.contacts
                        .save(self.contact)
                        .then(function() {
                            return {
                                name: 'states_end_success'
                            };
                        });
                }
            });
        });

        self.states.add('states_birth_year', function(name, opts) {
            var error = $('There was an error in your entry. Please ' +
                        'carefully enter your year of birth again (for ' +
                        'example: 2001)');

            var question = $('Since you don\'t have an ID or passport, ' +
                            'please enter the year that you were born (for ' +
                            'example: 1981)');

            return new FreeText(name, {
                question: question,

                check: function(content) {
                    if (!go.utils.check_number_in_range(content, 1900, go.utils.get_today(self.im.config).getFullYear())) {
                        return error;
                    }
                },

                next: function(content) {
                    self.contact.extra.birth_year = content;

                    return self.im.contacts
                        .save(self.contact)
                        .then(function() {
                            return {
                                name: 'states_birth_month'
                            };
                        });
                }
            });
        });

        self.states.add('states_birth_month', function(name) {
            return new ChoiceState(name, {
                question: $('Please enter the month that you were born.'),

                choices: go.utils.make_month_choices($, 0, 12),

                next: function(choice) {
                    self.contact.extra.birth_month = choice.value;

                    return self.im.contacts
                        .save(self.contact)
                        .then(function() {
                            return {
                                name: 'states_birth_day'
                            };
                        });
                }
            });
        });

        self.states.add('states_birth_day', function(name, opts) {
            var error = $('There was an error in your entry. Please ' +
                        'carefully enter your day of birth again (for ' +
                        'example: 8)');

            var question = $('Please enter the day that you were born ' +
                    '(for example: 14).');

            return new FreeText(name, {
                question: question,

                check: function(content) {
                    if (!go.utils.check_number_in_range(content, 1, 31)) {
                        return error;
                    }
                },

                next: function(content) {
                    if (content.length === 1) {
                        content = '0' + content;
                    }
                    self.contact.extra.birth_day = content;
                    self.contact.extra.dob = (self.im.user.answers.states_birth_year +
                        '-' + self.im.user.answers.states_birth_month +
                        '-' + content);
                    self.contact.extra.is_registered = 'true';
                    self.contact.extra.is_registered_by = 'personal';
                    self.contact.extra.metric_sessions_to_register = self.contact.extra.ussd_sessions;

                    return self.im.contacts
                        .save(self.contact)
                        .then(function() {
                            return Q.all([
                                self.im.metrics.fire.avg((self.metric_prefix + ".avg.sessions_to_register"),
                                    parseInt(self.contact.extra.ussd_sessions, 10)),
                                go.utils.incr_kv(self.im, [self.store_name, 'no_complete_registrations'].join('.')),
                                go.utils.decr_kv(self.im, [self.store_name, 'no_incomplete_registrations'].join('.')),
                                go.utils.adjust_percentage_registrations(self.im, self.metric_prefix)
                            ]);
                        })
                        .then(function() {
                            self.contact.extra.ussd_sessions = '0';
                            return self.im.contacts.save(self.contact);
                        })
                        .then(function() {
                            return {
                                name: 'states_end_success'
                            };
                        });
                }
            });
        });

        self.states.add('states_end_success', function(name) {
            return new EndState(name, {
                text: $('Thank you for subscribing to MomConnect. ' +
                        'You will now receive free messages about ' +
                        'MomConnect. Visit your nearest clinic to get ' +
                        'the full set of messages.'),

                next: 'states_start',

                events: {
                    'state:enter': function() {
                        return Q.all([
                            go.utils.jembi_send_json(self.contact, self.contact, 'subscription', self.im, self.metric_prefix),
                            go.utils.subscription_send_doc(self.contact, self.im, self.metric_prefix),
                            self.im.outbound.send_to_user({
                                endpoint: 'sms',
                                content: "Congratulations on your pregnancy. You will now get free SMSs about MomConnect. " +
                                         "You can register for the full set of FREE helpful messages at a clinic."
                            })
                        ]);
                    }
                }
            });
        });

        self.states.add('states_error', function(name) {
            return new EndState(name, {
              text: 'Sorry, something went wrong when saving the data. Please try again.',
              next: 'states_start'
            });
        });

    });

    return {
        GoNDOH: GoNDOH
    };
}();
<|endoftext|>


<commit_before>// Copyright 2013 The Obvious Corporation

var Q = require('q')
var util = require('util')
var assert = require('assert')
var ClearTimeoutEnvironment = require('./ClearTimeoutEnvironment')

/**
 * @param {Object} localExports The module exports object to add test methods to.
 * @constructor
 */
function Builder(localExports) {
  this._exports = localExports

  /** @private {Array} */
  this._environmentProviders = []

  /** @private {Array} */
  this._activeEnvironments = []

  /** @private {Function} */
  this._wrappedSetUp = localExports.setUp || this._defaultSetUpTearDown

  /** @private {Function} */
  this._wrappedTearDown = localExports.tearDown || this._defaultSetUpTearDown

  Object.defineProperty(localExports, 'setUp', {
    get: function () {
      return this._setUp.bind(this)
    }.bind(this),
    set: function (fn) {
      this._wrappedSetUp = fn
    }.bind(this)
  })

  Object.defineProperty(localExports, 'tearDown', {
    get: function () {
      return this._tearDown.bind(this)
    }.bind(this),
    set: function (fn) {
      this._wrappedTearDown = fn
    }.bind(this)
  })

  // The ClearTimeoutEnvironment is a default environment.
  this.addEnvironmentConstructor(ClearTimeoutEnvironment)
}

/**
 * Call as:
 * builder.add(function testName(test) { ... })
 * or
 * builder.add('testName', function(test) { ... })
 *
 * If the function returns a Q promise, then we will automatically handle failures
 * or call test.done() as appropriate.
 *
 * @param {Function|string} fnOrName The function, or the name of a function
 * @param {Function=} fn The function, if the first parameter was the name.
 */
Builder.prototype.add = function (fnOrName, fn) {
  var fn = fnOrName instanceof Function ? fnOrName : fn
  if (!(fn instanceof Function)) {
    throw new Error('Missing function')
  }

  var name = typeof fnOrName == 'string' ? fnOrName : fn.name
  if (!name) {
    throw new Error('Missing function name ' + fn.toString())
  }

  if (name in this._exports) {
    throw new Error('Test already exists for name ' + name)
  }

  this._exports[name] = function (test) {
    // Preserve the 'this' context.
    return runTestFunction.call(this, fn, test)
  }
}

/**
 * Call as:
 * builder.addEnvironment(environment)
 * where 'environment' is an object with 2 properties: setUp and tearDown.
 *
 * The environment will be invoked during setUp/tearDown
 *
 * @param {Object} env
 */
Builder.prototype.addEnvironment = function (env) {
  if (!env.setUp || !env.tearDown) {
    throw new Error('Environment constructor must have setUp and tearDown methods')
  }

  this._environmentProviders.push(function () {
    return env
  })
}

/**
 * Call as:
 * builder.addEnvironmentConstructor(Environment)
 * where 'Environment' is a constructor with 2 prototype properties: setUp and tearDown.
 *
 * The environment will be invoked during setUp/tearDown
 *
 * @param {Function} Ctor
 */
Builder.prototype.addEnvironmentConstructor = function (Ctor) {
  if (!Ctor.prototype.setUp || !Ctor.prototype.tearDown) {
    throw new Error('Environment constructor must have setUp and tearDown methods')
  }

  this._environmentProviders.push(function () {
    return new Ctor()
  })
}

/**
 * @param {Function} done
 * @private
 */
Builder.prototype._defaultSetUpTearDown = function (done) {
  done()
}

/**
 * Create and setUp all the environments in order, then run the wrapped
 * setUp function.
 * @param {Function} done
 * @private
 */
Builder.prototype._setUp = function (done) {
  var i = 0
  var advance = function () {
    var envProvider = this._environmentProviders[i++]
    if (!envProvider) {
      return null
    }

    var env = envProvider()
    this._activeEnvironments.push(env)
    var promise = env.setUp()
    if (promise && promise.then) {
      return promise.then(advance)
    } else {
      return advance()
    }
  }.bind(this)

  Q.resolve(true).then(advance).then(function () {
    var promise = this._wrappedSetUp.call(this._exports, done)
    if (promise && promise.then) {
      return promise.then(done)
    }
    return null
  }.bind(this))
  .fail(function (e) {
    console.error(_getErrorString(e))
  })
  .done()
}

/**
 * Tear down all the environments in reverse order, then run the wrapped
 * tearDown function.
 * @param {Function} done
 * @private
 */
Builder.prototype._tearDown = function (done) {
  var advance = function () {
    var env = this._activeEnvironments.pop()
    if (!env) {
      return null
    }

    var promise = env.tearDown()
    if (promise && promise.then) {
      return promise.then(advance)
    } else {
      return advance()
    }
  }.bind(this)

  Q.resolve(true).then(advance).then(function () {
    var promise = this._wrappedTearDown.call(this._exports, done)
    if (promise && promise.then) {
      return promise.then(done)
    }
    return null
  }.bind(this))
  .fail(function (e) {
    console.error(_getErrorString(e))
  })
  .done()
}

/**
 * Run a test function
 *
 * @param {Function} fn The function
 * @param {NodeUnit} test The nodeunit test object.
 */
function runTestFunction(fn, test) {
  try {
    // Preserve the 'this' context.
    var p = fn.call(this, test)

    // If this looks like a promise, attach all the appropriate handlers
    if (p && p.fail && p.fin) {
      p.fail(function (e) {
        _printError(test, e)
      })
      .fin(function () {
        test.done()
      })
    }
  } catch (e) {
    _printError(test, e)
    test.done()
  }
}

function _printError(test, e) {
  test.ok(false, _getErrorString(e))
}

function _getErrorString(e) {
  if (e && e.stack) {
    return String(e.stack)
  } else {
    return util.inspect(e, true, 5)
  }
}

// Nodeunit doesn't properly report uncaught errors, so we do this ourselves.
// In the worst case scenario, we'll report the error twice.
process.addListener('uncaughtException', function (err) {
  _printError(assert, err)
})

module.exports = Builder
<commit_msg>clean up promise detection<commit_after>// Copyright 2013 The Obvious Corporation

var Q = require('q')
var util = require('util')
var assert = require('assert')
var ClearTimeoutEnvironment = require('./ClearTimeoutEnvironment')

/**
 * @param {Object} localExports The module exports object to add test methods to.
 * @constructor
 */
function Builder(localExports) {
  this._exports = localExports

  /** @private {Array} */
  this._environmentProviders = []

  /** @private {Array} */
  this._activeEnvironments = []

  /** @private {Function} */
  this._wrappedSetUp = localExports.setUp || this._defaultSetUpTearDown

  /** @private {Function} */
  this._wrappedTearDown = localExports.tearDown || this._defaultSetUpTearDown

  Object.defineProperty(localExports, 'setUp', {
    get: function () {
      return this._setUp.bind(this)
    }.bind(this),
    set: function (fn) {
      this._wrappedSetUp = fn
    }.bind(this)
  })

  Object.defineProperty(localExports, 'tearDown', {
    get: function () {
      return this._tearDown.bind(this)
    }.bind(this),
    set: function (fn) {
      this._wrappedTearDown = fn
    }.bind(this)
  })

  // The ClearTimeoutEnvironment is a default environment.
  this.addEnvironmentConstructor(ClearTimeoutEnvironment)
}

/**
 * Call as:
 * builder.add(function testName(test) { ... })
 * or
 * builder.add('testName', function(test) { ... })
 *
 * If the function returns a Q promise, then we will automatically handle failures
 * or call test.done() as appropriate.
 *
 * @param {Function|string} fnOrName The function, or the name of a function
 * @param {Function=} fn The function, if the first parameter was the name.
 */
Builder.prototype.add = function (fnOrName, fn) {
  var fn = fnOrName instanceof Function ? fnOrName : fn
  if (!(fn instanceof Function)) {
    throw new Error('Missing function')
  }

  var name = typeof fnOrName == 'string' ? fnOrName : fn.name
  if (!name) {
    throw new Error('Missing function name ' + fn.toString())
  }

  if (name in this._exports) {
    throw new Error('Test already exists for name ' + name)
  }

  this._exports[name] = function (test) {
    // Preserve the 'this' context.
    return runTestFunction.call(this, fn, test)
  }
}

/**
 * Call as:
 * builder.addEnvironment(environment)
 * where 'environment' is an object with 2 properties: setUp and tearDown.
 *
 * The environment will be invoked during setUp/tearDown
 *
 * @param {Object} env
 */
Builder.prototype.addEnvironment = function (env) {
  if (!env.setUp || !env.tearDown) {
    throw new Error('Environment constructor must have setUp and tearDown methods')
  }

  this._environmentProviders.push(function () {
    return env
  })
}

/**
 * Call as:
 * builder.addEnvironmentConstructor(Environment)
 * where 'Environment' is a constructor with 2 prototype properties: setUp and tearDown.
 *
 * The environment will be invoked during setUp/tearDown
 *
 * @param {Function} Ctor
 */
Builder.prototype.addEnvironmentConstructor = function (Ctor) {
  if (!Ctor.prototype.setUp || !Ctor.prototype.tearDown) {
    throw new Error('Environment constructor must have setUp and tearDown methods')
  }

  this._environmentProviders.push(function () {
    return new Ctor()
  })
}

/**
 * @param {Function} done
 * @private
 */
Builder.prototype._defaultSetUpTearDown = function (done) {
  done()
}

/**
 * Create and setUp all the environments in order, then run the wrapped
 * setUp function.
 * @param {Function} done
 * @private
 */
Builder.prototype._setUp = function (done) {
  var i = 0
  var advance = function () {
    var envProvider = this._environmentProviders[i++]
    if (!envProvider) {
      return null
    }

    var env = envProvider()
    this._activeEnvironments.push(env)
    var promise = env.setUp()
    if (_isPromiseLike(promise)) {
      return promise.then(advance)
    } else {
      return advance()
    }
  }.bind(this)

  Q.resolve(true).then(advance).then(function () {
    var promise = this._wrappedSetUp.call(this._exports, done)
    if (_isPromiseLike(promise)) {
      return promise.then(done)
    }
    return null
  }.bind(this))
  .fail(function (e) {
    console.error(_getErrorString(e))
  })
  .done()
}

/**
 * Tear down all the environments in reverse order, then run the wrapped
 * tearDown function.
 * @param {Function} done
 * @private
 */
Builder.prototype._tearDown = function (done) {
  var advance = function () {
    var env = this._activeEnvironments.pop()
    if (!env) {
      return null
    }

    var promise = env.tearDown()
    if (_isPromiseLike(promise)) {
      return promise.then(advance)
    } else {
      return advance()
    }
  }.bind(this)

  Q.resolve(true).then(advance).then(function () {
    var promise = this._wrappedTearDown.call(this._exports, done)
    if (_isPromiseLike(promise)) {
      return promise.then(done)
    }
    return null
  }.bind(this))
  .fail(function (e) {
    console.error(_getErrorString(e))
  })
  .done()
}

function nullFunction() {}

/**
 * Run a test function
 *
 * @param {Function} fn The function
 * @param {NodeUnit} test The nodeunit test object.
 */
function runTestFunction(fn, test) {
  try {
    // Preserve the 'this' context.
    var p = fn.call(this, test)

    // If this looks like a promise, attach all the appropriate handlers
    if (_isPromiseLike(p)) {
      p.then(nullFunction, function (e) {
        _printError(test, e)
      })
      .then(function () {
        test.done()
      })
    }
  } catch (e) {
    _printError(test, e)
    test.done()
  }
}

function _isPromiseLike(obj) {
  return obj && obj.then && (typeof obj.then == 'function')
}

function _printError(test, e) {
  test.ok(false, _getErrorString(e))
}

function _getErrorString(e) {
  if (e && e.stack) {
    return String(e.stack)
  } else {
    return util.inspect(e, true, 5)
  }
}

// Nodeunit doesn't properly report uncaught errors, so we do this ourselves.
// In the worst case scenario, we'll report the error twice.
process.addListener('uncaughtException', function (err) {
  _printError(assert, err)
})

module.exports = Builder
<|endoftext|>