Askrround/botly

View on GitHub
lib/Botly.js

Summary

Maintainability
F
3 days
Test Coverage
'use strict';

const util = require('util');
const crypto = require('crypto');
const EventEmitter = require('events').EventEmitter;
const request = require('request');
const Router = require('express').Router;
const FB_MESSENGER_URL = (FB_URL) => `${FB_URL}me/messages`;

const BUTTON_TYPE = {
    POSTBACK: 'postback',
    WEB_URL: 'web_url',
    ACCOUNT_LINK: 'account_link',
    SHARE: 'element_share',
    CALL: 'phone_number'
};

const TEMPLATE_TYPE = {
    BUTTON: 'button',
    RECEIPT: 'receipt',
    GENERIC: 'generic',
    LIST: 'list',
    OPEN_GRAPH: 'open_graph'
};

const ATTACHMENT_TYPE = {
    IMAGE: 'image',
    FILE: 'file',
    VIDEO: 'video',
    AUDIO: 'audio',
    TEMPLATE: 'template'
};

const NOTIFICATION_TYPE = {
    REGULAR: 'REGULAR',
    SILENT_PUSH: 'SILENT_PUSH',
    NO_PUSH: 'NO_PUSH'
};

const CONTENT_TYPE = {
    TEXT: 'text',
    LOCATION: 'location'
};

const ACTION_TYPES = {
    MARK_SEEN: 'mark_seen',
    TYPING_ON: 'typing_on',
    TYPING_OFF: 'typing_off',
};

const WEBVIEW_HEIGHT_RATIO = {
    FULL: 'full',
    TALL: 'tall',
    COMPACT: 'compact'
};

const TOP_ELEMENT_STYLE = {
    LARGE: 'large',
    COMPACT: 'compact'
};

const IMAGE_ASPECT_RATIO = {
    HORIZONTAL: 'horizontal',
    SQUARE: 'square'
};

const USER_PROFILE_FIELD = {
    ID: 'id',
    NAME: 'name',
    FIRST_NAME: 'first_name',
    LAST_NAME: 'last_name',
    PROFILE_PIC: 'profile_pic',
    LOCALE: 'locale',
    TIMEZONE: 'timezone',
    GENDER: 'gender'
};

function Botly(options) {
    if (!(this instanceof Botly)) {
        return new Botly(options);
    }

    if (!options || !options.accessToken) {
        throw new Error('Must provide accessToken');
    }

    this.FB_URL = options.FB_URL || 'https://graph.facebook.com/v2.12/';

    this.accessToken = options.accessToken;
    this.verifyToken = options.verifyToken;
    this.webHookPath = options.webHookPath || '/';
    this.notificationType = options.notificationType || NOTIFICATION_TYPE.REGULAR;
    EventEmitter.call(this);
}

util.inherits(Botly, EventEmitter);

Botly.prototype.router = function () {

    const router = Router();

    router.get(this.webHookPath, (req, res) => {
        if (req.query['hub.mode'] === 'subscribe' &&
            req.query['hub.verify_token'] === this.verifyToken) {
            res.send(req.query['hub.challenge']);
        } else {
            res.status(403).send('Error, wrong validation token');
        }
    });

    router.post(this.webHookPath, (req, res) => {
        res.sendStatus(200); //return 200 to facebook no matter what
        try {
            this.handleMessage(req);
        } catch (ex) {
            this.emit('error', ex);
        }
    });

    return router;
};

Botly.prototype.getVerifySignature = function (secret) {
    return (req, res, buf) => {
        const expected = req.headers['x-hub-signature'];
        const hmac = crypto.createHmac('sha1', secret).update(buf, 'utf-8').digest('hex');
        const signature = `sha1=${hmac}`;

        if (signature !== expected) {
            throw new Error('Invalid X-Hub-Signature');
        }
    };
};

Botly.prototype.getPSID = function (options, callback) {
    let accountLinkingToken = options;
    if (typeof options === 'object') {
        accountLinkingToken = options.token;
    }
    const USER_URL = `${this.FB_URL}me`;

    request.get(
        {
            url: USER_URL,
            qs: {
                fields: 'recipient',
                account_linking_token: accountLinkingToken,
                access_token: this.accessToken || options.accessToken
            },
            json: true

        }, (err, res, body) => {
            if (callback) {
                callback(err, body);
            }
        });
};

Botly.prototype.getUserProfile = function (options, callback) {
    let userId = options;
    let fields = [
        USER_PROFILE_FIELD.FIRST_NAME,
        USER_PROFILE_FIELD.LAST_NAME,
        USER_PROFILE_FIELD.PROFILE_PIC
    ];
    if (typeof options === 'object') {
        userId = options.id;
    }
    if (Array.isArray(options.fields) && options.fields.length > 0) {
        fields = options.fields;
    }
    const USER_URL = `${this.FB_URL}${userId}`;

    request.get(
        {
            url: USER_URL,
            qs: {
                fields: fields.join(','),
                access_token: this.accessToken || options.accessToken
            },
            json: true

        }, (err, res, body) => {
            if (callback) {
                callback(err, body);
            }
        });
};

Botly.prototype.removePersistentMenu = function (options, callback) {
   const PAGE_URL = `${this.FB_URL}${options.pageId}/messenger_profile`;
   request.delete(
        {
            url: PAGE_URL,
            qs: {
                fields: ['persistent_menu'],
                access_token: this.accessToken || options.accessToken
            },
            json: true
        }, (err, res, body) => {
            if (callback) {
                callback(err, body);
            }
    });
};

Botly.prototype.setGetStarted = function (options, callback) {
    options.body = {
        get_started: {
            payload: options.payload
        }
    };
    this.setProfile(options, callback);
};

Botly.prototype.setGreetingText = function (options, callback) {
    options.body = {
        greeting: options.greeting
    };
    this.setProfile(options, callback);
};

Botly.prototype.setPersistentMenu = function (options, callback) {
    options.body = {
        persistent_menu: options.menu
    };
    this.setProfile(options, callback);
};

Botly.prototype.setWhiteList = function (options, callback) {
    options.body = {
        whitelisted_domains: options.whiteList,
    };
    this.setProfile(options, callback);
};

Botly.prototype.setTargetAudience = function (options, callback) {
    options.body = {
        target_audience: options.audience,
    };
    this.setProfile(options, callback);
};

Botly.prototype.setAccountLinkingURL = function (options, callback) {
    options.body = {
        account_linking_url: options.url,
    };
    this.setProfile(options, callback);
};

Botly.prototype.setProfile = function (options, callback) {
    const PAGE_URL = `${this.FB_URL}${options.pageId}/messenger_profile`;

    request.post(
        {
            url: PAGE_URL,
            json: true,
            qs: {
                access_token: this.accessToken || options.accessToken
            },
            body: options.body

        }, (err, res, body) => {
            if (callback) {
                callback(err, body);
            }
        });
};

Botly.prototype.upload = function (options, callback) {
    const PAGE_URL = `${this.FB_URL}${options.pageId}/message_attachments`;
    options.message = options.message || {
            attachment: {
                type: options.type,
                payload: options.payload
            }
        };
    if (options.filedata) {
        options.message.filedata = options.filedata;
    }
    request.post(
        {
            url: PAGE_URL,
            json: true,
            qs: {
                access_token: this.accessToken || options.accessToken
            },
            body: {
                message: options.message
            }
        }, (err, res, body) => {
            if (callback) {
                callback(err, body);
            }
        });
};

Botly.prototype.send = function (options, callback) {
    options.notificationType = options.notificationType || this.notificationType;
    request.post(
        {
            url: FB_MESSENGER_URL(this.FB_URL),
            json: true,
            qs: {
                access_token: this.accessToken || options.accessToken
            },
            body: {
                messaging_type: options.messaging_type || 'RESPONSE',
                recipient: {id: options.id},
                message: options.message,
                notification_type: options.notificationType
            }

        }, (err, res, body) => {
            if (callback) {
                callback(err, body);
            }
            this.emit('sent', options.id, options.message, body);
        });
};

Botly.prototype.sendAction = function (options, callback) {
    request.post(
        {
            url: FB_MESSENGER_URL(this.FB_URL),
            json: true,
            qs: {
                access_token: this.accessToken || options.accessToken
            },
            body: {
                messaging_type: 'RESPONSE',
                recipient: {id: options.id},
                sender_action: options.action
            }

        }, (err, res, body) => {
            if (callback) {
                callback(err, body);
            }
        });
};

Botly.prototype.sendAttachment = function (options, callback) {
    options.message = options.message || {
            attachment: {
                type: options.type,
                payload: options.payload
            }
        };
    if (options.filedata) {
        options.message.filedata = options.filedata;
    }
    if (options.quick_replies) {
        options.message.quick_replies = options.quick_replies;
    }
    this.send(options, callback);
};

Botly.prototype.sendImage = function (options, callback) {
    options.payload = options.payload || {
            url: options.url,
        };
    if (options.is_reusable) {
        options.payload.is_reusable = options.is_reusable;
    }
    options.type = ATTACHMENT_TYPE.IMAGE;
    this.sendAttachment(options, callback);
};

Botly.prototype.sendText = function (options, callback) {
    options.message = options.message || {
            text: options.text
        };
    if (options.quick_replies) {
        options.message.quick_replies = options.quick_replies;
    }
    this.send(options, callback);
};

Botly.prototype.sendButtons = function (options, callback) {
    options.payload = this.createButtonTemplate(options.text, options.buttons);
    options.type = ATTACHMENT_TYPE.TEMPLATE;
    this.sendAttachment(options, callback);
};

Botly.prototype.sendGeneric = function (options, callback) {
    options.payload = this.createGenericTemplate(options.elements, options.aspectRatio);
    options.type = ATTACHMENT_TYPE.TEMPLATE;
    this.sendAttachment(options, callback);
};

Botly.prototype.sendOpenGraph = function (options, callback) {
    options.payload = this.createOpenGraphTemplate(options.elements);
    options.type = ATTACHMENT_TYPE.TEMPLATE;
    this.sendAttachment(options, callback);
};

Botly.prototype.sendList = function (options, callback) {
    options.payload = this.createListTemplate(options);
    options.type = ATTACHMENT_TYPE.TEMPLATE;
    this.sendAttachment(options, callback);
};

Botly.prototype.sendReceipt = function (options, callback) {
    options.payload.template_type = TEMPLATE_TYPE.RECEIPT;
    options.type = ATTACHMENT_TYPE.TEMPLATE;
    this.sendAttachment(options, callback);
};

Botly.prototype.createWebURLButton = function (title, url, heightRatio, supportExtension, fallbackURL, shareDisabled) {
    if (typeof title === 'object') {
        fallbackURL = title.fallbackURL;
        supportExtension = title.supportExtension;
        heightRatio = title.heightRatio;
        url = title.url;
        title = title.title;
    }
    let button = {
        type: BUTTON_TYPE.WEB_URL,
        title: title,
        url: url
    };
    if (heightRatio) {
        button.webview_height_ratio = heightRatio;
    }
    if (supportExtension) {
        button.messenger_extensions = supportExtension;
    }
    if (fallbackURL) {
        button.fallback_url = fallbackURL;
    }
    if (shareDisabled) {
        button.webview_share_button = 'hide';
    }
    return button;
};

Botly.prototype.createAccountLinkButton = function (url) {
    return {
        type: BUTTON_TYPE.ACCOUNT_LINK,
        url: url
    };
};

Botly.prototype.createShareButton = function () {
    return {
        type: BUTTON_TYPE.SHARE,
    };
};

Botly.prototype.createPostbackButton = function (title, payload) {
    return {
        type: BUTTON_TYPE.POSTBACK,
        title: title,
        payload: payload
    };
};

Botly.prototype.createQuickReply = function (title, payload, imageURL) {
    let reply = {
        'content_type': CONTENT_TYPE.TEXT,
        'title': title,
        'payload': payload,
    };
    if (imageURL) {
        reply.image_url = imageURL;
    }
    return reply;
};

Botly.prototype.createShareLocation = function () {
    return {
        'content_type': CONTENT_TYPE.LOCATION,
    };
};

Botly.prototype.createButtonTemplate = function (text, buttons) {
    if (!Array.isArray(buttons)) {
        buttons = [buttons];
    }
    return {
        template_type: TEMPLATE_TYPE.BUTTON,
        text: text,
        buttons: buttons
    };
};

Botly.prototype.createListElement = function (options) {
    let element = {
        title: options.title
    };
    let buttons = options.buttons;
    if (buttons) {
        if (!Array.isArray(buttons)) {
            buttons = [buttons];
        }
        buttons = buttons.map(button => {
            if (button.url) {
                return this.createWebURLButton(button);
            } else if (button.payload) {
                return this.createPostbackButton(button.title, button.payload);
            } else {
                return button;
            }
        });
        element.buttons = buttons;
    }
    if (options.subtitle) {
        element.subtitle = options.subtitle;
    }
    if (options.image_url) {
        element.image_url = options.image_url;
    }
    if (options.default_action) {
        options.default_action.type = BUTTON_TYPE.WEB_URL;
        element.default_action = options.default_action;
    }

    return element;
};

Botly.prototype.createGenericTemplate = function (elements, aspectRatio) {
    if (!Array.isArray(elements)) {
        elements = [elements];
    }
    return {
        image_aspect_ratio: aspectRatio || IMAGE_ASPECT_RATIO.HORIZONTAL,
        template_type: TEMPLATE_TYPE.GENERIC,
        elements: elements
    };
};

Botly.prototype.createOpenGraphElement = function (url, title) {
    let button = Botly.prototype.createWebURLButton(title, url);
    return {
      url: url,
      buttons: [ button ]
    };
};

Botly.prototype.createOpenGraphTemplate = function (elements) {
    if (!Array.isArray(elements)) {
        elements = [elements];
    }
    return {
        template_type: TEMPLATE_TYPE.OPEN_GRAPH,
        elements: elements
    };
};

Botly.prototype.createListTemplate = function (options) {
    let elements = options.elements;
    let buttons = options.buttons;
    if (!Array.isArray(elements)) {
        elements = [elements];
    }
    let template = {
        top_element_style: options.top_element_style || TOP_ELEMENT_STYLE.LARGE,
        template_type: TEMPLATE_TYPE.LIST,
        elements: elements
    };
    if (buttons) {
        if (!Array.isArray(buttons)) {
            buttons = [buttons];
        }
        template.buttons = buttons;
    }
    return template;
};

Botly.prototype.handleMessage = function (req) {
    const body = _clone(req.body);

    this.emit('complete_message', body);
    body.entry.forEach(entry => {
        if (entry.messaging && entry.messaging.length) {
          entry.messaging.forEach(message => {
            _handleOptIn.call(this, message);
            _handleDelivery.call(this, message);
            _handleRead.call(this, message);
            _handlePostback.call(this, message);
            _handleReferral.call(this, message);
            _handleAccountLinking.call(this, message);
            _handleUserMessage.call(this, message);
            _handleEcho.call(this, message);
            _handleThreadControl.call(this, message);
          }, this);
        }
    }, this);
};

function _handleOptIn(message) {
    if (message.optin) {
        let optin = message.optin.ref;
        this.emit('optin', message.sender.id, message, optin);
    }
}

function _handleDelivery(message) {
    if (message.delivery) {
        let mids = message.delivery.mids;
        this.emit('delivery', message.sender.id, message, mids);
    }
}

function _handleRead(message) {
    if (message.read) {
        let recipient = message.recipient.id;
        this.emit('read', message.sender.id, recipient, message.read);
    }
}

function _handlePostback(message) {
    if (message.postback || (message.message && !message.message.is_echo && message.message.quick_reply)) {
        let postback = (message.postback && message.postback.payload) || message.message.quick_reply.payload;
        let ref = message.postback && message.postback.referral && message.postback.referral.ref;
        this.emit('postback', message.sender.id, message, postback, ref);
    }
}

function _handleReferral(message) {
    if (message.referral) {
        let ref = message.referral.ref;
        this.emit('referral', message.sender.id, message, ref);
    }
}

function _handleAccountLinking(message) {
    if (message.account_linking) {
        let link = message.account_linking;
        this.emit('account_link', message.sender.id, message, link);
    }
}

function _handleUserMessage(message) {
    var value;
    if (message.message && !message.message.is_echo && message.message.text && !message.message.quick_reply) {
        value = {text: message.message.text};
    }

    if (message.message && !message.message.is_echo && message.message.attachments) {
        let attachments = {};
        message.message.attachments.forEach((attachment) => {
            if (attachment.type && attachment.payload) {
                attachments[attachment.type] = attachments[attachment.type] || [];
                attachments[attachment.type].push(attachment.payload.url || attachment.payload.coordinates);
            }
        });
        value = {attachments: attachments};
    }

    if (value) {
        this.emit('message', message.sender.id, message, value);
    }
}

function _handleEcho(message) {
    if (message.message && message.message.is_echo) {
        this.emit('echo', message.sender.id, message, message.message, message.recipient.id);
    }
}

function _handleThreadControl(message) {
    if (message.pass_thread_control) {
        this.emit('passThreadControl', message.sender.id, message, message.pass_thread_control, message.recipient.id);
    }
}

function _clone(obj) {
    return JSON.parse(JSON.stringify(obj));
}

Botly.CONST = {
    BUTTON_TYPE: BUTTON_TYPE,
    TEMPLATE_TYPE: TEMPLATE_TYPE,
    ATTACHMENT_TYPE: ATTACHMENT_TYPE,
    NOTIFICATION_TYPE: NOTIFICATION_TYPE,
    ACTION_TYPES: ACTION_TYPES,
    CONTENT_TYPE: CONTENT_TYPE,
    WEBVIEW_HEIGHT_RATIO: WEBVIEW_HEIGHT_RATIO,
    TOP_ELEMENT_STYLE: TOP_ELEMENT_STYLE,
    IMAGE_ASPECT_RATIO: IMAGE_ASPECT_RATIO,
    USER_PROFILE_FIELD: USER_PROFILE_FIELD
};

module.exports = Botly;