UNPKG

crisp-api

Version:

Crisp API wrapper for Node - official, maintained by Crisp

1,172 lines (1,006 loc) 28.7 kB
/* * node-crisp-api * * Copyright 2022, Crisp IM SAS * Author: Baptiste Jamin <baptiste@crisp.chat> */ "use strict"; // Imports var pkg = require("../package.json"); var got = require("got"); var URL = require("url").URL; var Crypto = require("crypto"); var EventEmitter = require("fbemitter").EventEmitter; // RTM modes available Crisp.RTM_MODES = { WebSockets : "websockets", WebHooks : "webhooks" }; Crisp.AVAILABLE_RTM_MODES = [ Crisp.RTM_MODES.WebSockets, Crisp.RTM_MODES.WebHooks ]; // Base configuration Crisp.DEFAULT_REQUEST_TIMEOUT = 10000; Crisp.DEFAULT_SOCKET_TIMEOUT = 10000; Crisp.DEFAULT_SOCKET_RECONNECT_DELAY = 5000; Crisp.DEFAULT_SOCKET_RECONNECT_DELAY_MAX = 10000; Crisp.DEFAULT_SOCKET_RECONNECT_FACTOR = 0.75; Crisp.DEFAULT_BROKER_SCHEDULE = 500; Crisp.DEFAULT_EVENT_REBIND_INTERVAL_MIN = 2500; Crisp.DEFAULT_USERAGENT_PREFIX = "node-crisp-api/"; // REST API defaults Crisp.DEFAULT_REST_HOST = "https://api.crisp.chat"; Crisp.DEFAULT_REST_BASE_PATH = "/v1/"; // RTM API defaults Crisp.DEFAULT_RTM_MODE = Crisp.RTM_MODES.WebSockets; Crisp.DEFAULT_RTM_EVENTS = [ // Session Events "session:update_availability", "session:update_verify", "session:request:initiated", "session:set_email", "session:set_phone", "session:set_address", "session:set_subject", "session:set_avatar", "session:set_nickname", "session:set_origin", "session:set_data", "session:sync:pages", "session:sync:events", "session:sync:capabilities", "session:sync:geolocation", "session:sync:system", "session:sync:network", "session:sync:timezone", "session:sync:locales", "session:sync:rating", "session:sync:topic", "session:set_state", "session:set_block", "session:set_segments", "session:set_opened", "session:set_closed", "session:set_participants", "session:set_mentions", "session:set_routing", "session:set_inbox", "session:removed", "session:error", // Message Events "message:updated", "message:send", "message:received", "message:removed", "message:compose:send", "message:compose:receive", "message:acknowledge:read:send", "message:acknowledge:read:received", "message:acknowledge:unread:send", "message:acknowledge:delivered", "message:acknowledge:ignored", "message:notify:unread:send", "message:notify:unread:received", // Spam Events "spam:message", "spam:decision", // People Events "people:profile:created", "people:profile:updated", "people:profile:removed", "people:bind:session", "people:sync:profile", "people:import:progress", "people:import:done", // Campaign Events "campaign:progress", "campaign:dispatched", "campaign:running", // Browsing Events "browsing:request:initiated", "browsing:request:rejected", // Call Events "call:request:initiated", "call:request:rejected", // Identity Events "identity:verify:request", // Status Events "status:health:changed", // Website Event "website:update_visitors_count", "website:update_operators_availability", "website:users:available", // Bucket Events "bucket:url:upload:generated", "bucket:url:avatar:generated", "bucket:url:website:generated", "bucket:url:campaign:generated", "bucket:url:helpdesk:generated", "bucket:url:status:generated", "bucket:url:processing:generated", "bucket:url:crawler:generated", // Media Events "media:animation:listed", // Email Event "email:subscribe", "email:track:view", // Plugin Events "plugin:channel", "plugin:event", "plugin:settings:saved", ]; // REST API services var services = { Bucket : require("./services/Bucket"), Media : require("./services/Media"), Plugin : require("./services/Plugin"), Website : require("./services/Website") }; /** * Crisp API Library * @class * @classdesc This is the Crisp Library. Handles REST and RTM operations */ function Crisp() { /** * @public * @type {*} */ this.bucket = {}; /** * @public * @type {*} */ this.media = {}; /** * @public * @type {*} */ this.plugin = {}; /** * @public * @type {*} */ this.website = {}; /** * @public * @type {object} */ this.auth = { tier : "user", identifier : null, key : null, token : null }; /** * @private * @type {object} */ this._rest = { host : Crisp.DEFAULT_REST_HOST, basePath : Crisp.DEFAULT_REST_BASE_PATH }; /** * @private * @type {object} */ this._rtm = { host : null, mode : Crisp.DEFAULT_RTM_MODE }; /** * @private * @type {string} */ this._useragent = (Crisp.DEFAULT_USERAGENT_PREFIX + pkg.version); /** * @private * @type {object} */ this._emitter = new EventEmitter(); /** * @private * @type {object|null} */ this._socket = null; /** * @private * @type {object|null} */ this._loopback = null; /** * @private * @type {number|null} */ this._lastEventRebind = null; /** * @private * @type {object|null} */ this._brokerScheduler = null; /** * @private * @type {Array} */ this._brokerBindHooks = []; /** * @private * @type {object} */ this._boundEvents = {}; // Prepare this._prepareServices(); } Crisp.prototype = { /** * Sets the REST API host * @memberof Crisp * @public * @method setRestHost * @param {string} host - Hostname * @return {undefined} */ setRestHost : function(host) { if (typeof host === "string") { this._rest.host = host; } else { throw new Error("[Crisp] setRestHost: parameter host should be a string"); } }, /** * Sets the RTM API host * @memberof Crisp * @public * @method setRtmHost * @param {string} host - Hostname * @return {undefined} */ setRtmHost : function(host) { if (typeof host === "string") { this._rtm.host = host; } else { throw new Error("[Crisp] setRtmHost: parameter host should be a string"); } }, /** * Sets the RTM channel mode (ie. WebSockets or Web Hooks) * @memberof Crisp * @public * @method setRtmMode * @param {string} mode - RTM mode ('websockets' or 'webhooks') * @return {undefined} */ setRtmMode : function(mode) { if (Crisp.AVAILABLE_RTM_MODES.indexOf(mode) !== -1) { this._rtm.mode = mode; } else { throw new Error( "[Crisp] setRtmMode: parameter mode value should be one of: " + Crisp.AVAILABLE_RTM_MODES.join(", ") ); } }, /** * Sets the authentication tier * @memberof Crisp * @public * @method setTier * @param {string} tier * @return {undefined} */ setTier : function(tier) { this.auth.tier = (tier || "user"); }, /** * Authenticates * @memberof Crisp * @public * @method authenticate * @param {string} identifier * @param {string} key * @return {undefined} */ authenticate : function(identifier, key) { var auth = this.auth; // Store credentials auth.identifier = identifier; auth.key = key; // Assign pre-computed authentication token auth.token = Buffer.from(identifier + ":" + key).toString("base64"); }, /** * Authenticates (with tier) * @memberof Crisp * @public * @method authenticateTier * @param {string} tier * @param {string} identifier * @param {string} key * @return {undefined} */ authenticateTier : function(tier, identifier, key) { this.setTier(tier); this.authenticate(identifier, key); }, /** * Method wrapper to HEAD a resource * @memberof Crisp * @public * @method head * @param {string} resource * @param {object} query * @param {object} body * @return {Promise} */ head : function(resource, query, body) { var self = this; return new Promise(function(resolve, reject) { self._request( resource, "head", (query || {}), null, resolve, reject ); }); }, /** * Method wrapper to GET a resource * @memberof Crisp * @public * @method get * @param {string} resource * @param {object} query * @param {object} body * @return {Promise} */ get : function(resource, query) { var self = this; return new Promise(function(resolve, reject) { self._request( resource, "get", (query || {}), null, resolve, reject ); }); }, /** * Method wrapper to POST a resource * @memberof Crisp * @public * @method post * @param {string} resource * @param {object} query * @param {object} body * @return {Promise} */ post : function(resource, query, body) { var self = this; return new Promise(function(resolve, reject) { self._request( resource, "post", (query || {}), (body || {}), resolve, reject ); }); }, /** * Method wrapper to PATCH a resource * @memberof Crisp * @public * @method patch * @param {string} resource * @param {object} query * @param {object} body * @return {Promise} */ patch : function(resource, query, body) { var self = this; return new Promise(function(resolve, reject) { self._request( resource, "patch", (query || {}), (body || {}), resolve, reject ); }); }, /** * Method wrapper to PUT a resource * @memberof Crisp * @public * @method put * @param {string} resource * @param {object} query * @param {object} body * @return {Promise} */ put : function(resource, query, body) { var self = this; return new Promise(function(resolve, reject) { self._request( resource, "put", (query || {}), (body || {}), resolve, reject ); }); }, /** * Method wrapper to DELETE a resource * @memberof Crisp * @public * @method delete * @param {string} resource * @param {object} query * @param {object} body * @return {Promise} */ delete : function(resource, query, body) { var self = this; return new Promise(function(resolve, reject) { self._request( resource, "delete", (query || {}), (body || null), resolve, reject ); }); }, /** * Binds RTM event * @memberof Crisp * @public * @method on * @param {string} event * @param {function} callback * @return {Promise} */ on : function(event, callback) { // Ensure all input arguments are set if (typeof event !== "string") { throw new Error("[Crisp] on: parameter event should be a string"); } if (typeof callback !== "function") { throw new Error("[Crisp] on: parameter callback should be a function"); } // Disallow unrecognized event names if (Crisp.DEFAULT_RTM_EVENTS.indexOf(event) === -1) { throw new Error( "[Crisp] on: parameter event value is not recognized: '" + event + "'" ); } // Important: we do not allow .on() to be called once socket is connected, \ // or loopback is bound as we consider event listeners must be bound \ // once all together. This prevents bogous integrations from sending \ // flood of 'socket:bind'` to the RTM API, if using WebSockets. Web \ // Hooks follows the same scheme for consistency's sake. if (this._socket || this._loopback) { throw new Error( "[Crisp] on: connector is already bound, please listen to event " + "earlier on: '" + event + "'" ); } // Add listener to emitter this._emitter.addListener(event, callback); // Subscribe event on the broker if (this._boundEvents[event] !== true) { var rtmMode = this._rtm.mode; // Mark event as bound this._boundEvents[event] = true; // Broker not connected? Connect now. return this._prepareBroker( function(instance, emitter) { // Listen for event? (once instance is bound) switch (rtmMode) { case Crisp.RTM_MODES.WebSockets: { // Listen on socket event instance.on(event, function(data) { emitter.emit(event, data); }); break; } } } ); } return Promise.resolve(); }, /** * Receives a raw event and dispatches it to the listener (used for Web Hooks) * @memberof Crisp * @public * @method receiveHook * @param {object} body * @return {undefined} */ receiveHook : function(body) { var self = this; if (self._loopback) { // Ensure payload is readable if (!body || typeof body !== "object") { return new Error("[Crisp] receiveHook: empty hook payload"); } // Ensure payload is properly formatted if (!body.event || !body.data || typeof body.event !== "string" || typeof body.data !== "object") { return new Error("[Crisp] receiveHook: malformatted hook payload"); } // Check if event is subscribed to? (in routing table) // Notice: if not in routing table, then silently discard the event w/o \ // any error, as we do not want an HTTP failure status to be sent in \ // response by the implementor. if (self._boundEvents[body.event] !== true) { return null; } // Dispatch event to event bus // Notice: go asynchronous, so that the event is processed ASAP and \ // dispatched on the event bus later, as the hook might be received \ // synchronously over HTTP. process.nextTick(function() { self._loopback.emit(body.event, body.data); }); return null; } return new Error("[Crisp] receiveHook: hook loopback not bound"); }, /** * Verifies an event string and checks that signatures match (used for Web \ * Hooks) * @memberof Crisp * @public * @method verifyHook * @param {string} secret * @param {object} body * @param {string} timestamp * @param {string} signature * @return {boolean} */ verifyHook : function(secret, body, timestamp, signature) { if (this._loopback) { return this._verifySignature(secret, body, timestamp, signature); } // Default: not verified (loopback not /yet?/ bound) return false; }, /** * Verifies an event string and checks that signatures match (used for \ * Widgets) * @memberof Crisp * @public * @method verifyWidget * @param {string} secret * @param {object} body * @param {string} timestamp * @param {string} signature * @return {boolean} */ verifyWidget : function(secret, body, timestamp, signature) { return this._verifySignature(secret, body, timestamp, signature); }, /** * Rebinds socket events (used for WebSockets) * @memberof Crisp * @public * @method rebind * @return {Promise} */ rebindSocket : function() { var self = this; if (!self._socket) { throw new Error( "[Crisp] rebindSocket: cannot rebind a socket that is not yet bound" ); } // Make sure that the library user is not rebinding too frequently (which \ // is illegal) var nowTime = Date.now(); if (self._lastEventRebind !== null && ((nowTime - self._lastEventRebind) < Crisp.DEFAULT_EVENT_REBIND_INTERVAL_MIN)) { throw new Error( "[Crisp] rebindSocket: cannot rebind, last rebind was requested too " + "recently" ); } return Promise.resolve() .then(function() { // Rebind to socket events (eg. newly bound websites) self._lastEventRebind = nowTime; self._socket.emit("socket:bind", {}); return Promise.resolve(); }); }, /** * Prepares a URI based from path segments * @memberof Crisp * @private * @method _prepareRestUrl * @param {Array} paths - List of paths ['session', 'login'] * @return {string} */ _prepareRestUrl : function(paths) { if (Array.isArray(paths) === true) { var output = this._rest.host + this._rest.basePath; output += paths.join("/"); return output; } throw new Error( "[Crisp] prepareRestUrl: parameter host should be an Array" ); }, /** * Binds services to the main object * @memberof Crisp * @private * @method _prepareServices * @return {undefined} */ _prepareServices : function() { // Bind services for (var name in services) { var serviceInstance = new services[name](); // Acquire service map var serviceMap = this[(name[0].toLowerCase() + name.substring(1))]; // No service map available? if (!serviceMap) { throw new Error( "[Crisp] prepareServices: service '" + name + "' has no map available" ); } // No resources defined in service? if (!serviceInstance._resources || serviceInstance._resources.length === 0) { throw new Error( "[Crisp] prepareServices: service '" + name + "' has no resources " + "defined" ); } // Prepare all resources (for service) this._prepareResources( serviceMap, serviceInstance._resources ); } }, /** * Binds resources to the service object * @memberof Crisp * @private * @method _prepareResources * @param {object} serviceMap * @param {Array} resources * @return {undefined} */ _prepareResources : function(serviceMap, resources) { for (var i = 0; i < resources.length; i++) { var resourceConstructor = require("./resources/" + resources[i]); // Instanciate resource, which will auto-bind itself to service prototype new resourceConstructor(serviceMap, this); } }, /** * Binds broker to the main object * @memberof Crisp * @private * @method _prepareBroker * @param {function} fnBindHook * @return {Promise} */ _prepareBroker : function(fnBindHook) { var self = this; return new Promise(function(resolve, reject) { var rtmMode = self._rtm.mode, rtmHostOverride = self._rtm.host; // Append bind hook to pending stack self._brokerBindHooks.push(fnBindHook); // Make sure to prepare broker once? (defer broker binding, waiting that \ // all listeners have been bound, that way we submit the list of \ // filtered events to the RTM API once, and never again in the future) if (self._brokerScheduler === null) { // Socket or loopback already set? We should not even have entered \ // there. if (self._socket || self._loopback) { throw new Error( "[Crisp] prepareBroker: illegal call to prepare broker (tie break)" ); } self._brokerScheduler = setTimeout(function() { switch (rtmMode) { case Crisp.RTM_MODES.WebSockets: { // Connect to socket now // Notice: will unstack broker bind hooks once ready self._connectSocket(rtmHostOverride) .then(resolve) .catch(reject); break; } case Crisp.RTM_MODES.WebHooks: { // Connect to loopback now self._connectLoopback() .then(resolve) .catch(reject); break; } default: { var unsupportedError = new Error( "[Crisp] prepareBroker: mode of RTM broker unsupported " + "('" + rtmMode + "')" ); reject(unsupportedError); } } }, Crisp.DEFAULT_BROKER_SCHEDULE); } else { // Pass-through resolve(); } }); }, /** * Connects loopback (used for Web Hooks) * @memberof Crisp * @private * @method _connectLoopback * @return {Promise} */ _connectLoopback : function() { var self = this; return Promise.resolve() .then(function() { // Assign emitter to loopback self._loopback = self._emitter; // Unstack broker bind hooks immediately self._unstackBrokerBindHooks(self._loopback); return Promise.resolve(); }); }, /** * Connects socket, using preferred RTM API host (used for WebSockets) * @memberof Crisp * @private * @method _connectSocket * @param {string} rtmHostOverride * @return {Promise} */ _connectSocket : function(rtmHostOverride) { var self = this; return Promise.resolve() .then(function() { // Any override RTM API host? if (rtmHostOverride) { return Promise.resolve({ socket : { app : rtmHostOverride } }); } // Acquire RTM API URL from remote var restUrlSegments; switch (self.auth.tier) { case "plugin": { restUrlSegments = ["plugin", "connect", "endpoints"]; break; } default: { restUrlSegments = ["user", "connect", "endpoints"]; } } return self.get( self._prepareRestUrl(restUrlSegments) ) .catch(function() { // Void error (consider as empty response) return Promise.resolve({}); }); }) .then(function(endpoints) { var rtmHostAffinity = ((endpoints.socket || {}).app || null); // No RTM API host acquired? if (rtmHostAffinity === null) { throw new Error( "[Crisp] connectSocket: could not acquire target host to " + "connect to, is your session valid for tier?" ); } // Parse target RTM API host as an URL object var rtmHostUrl = new URL(rtmHostAffinity); // Connect to socket self._socket = require("socket.io-client")(rtmHostUrl.origin, { path : (rtmHostUrl.pathname || "/"), transports : ["websocket"], timeout : Crisp.DEFAULT_SOCKET_TIMEOUT, reconnection : true, reconnectionDelay : Crisp.DEFAULT_SOCKET_RECONNECT_DELAY, reconnectionDelayMax : Crisp.DEFAULT_SOCKET_RECONNECT_DELAY_MAX, randomizationFactor : Crisp.DEFAULT_SOCKET_RECONNECT_FACTOR }); self._emitAuthenticateSocket(); // Setup base socket event listeners self._socket.io.on("reconnect", function() { self._emitAuthenticateSocket(); }); self._socket.on("unauthorized", function() { throw new Error( "[Crisp] connectSocket: cannot listen for events as " + "authentication is invalid" ); }); // Setup user socket event listeners self._unstackBrokerBindHooks(self._socket); return Promise.resolve(); }); }, /** * Authenticates client (used for WebSockets) * @memberof Crisp * @private * @method _emitAuthenticateSocket * @return {undefined} */ _emitAuthenticateSocket : function() { var auth = this.auth, boundEvents = Object.keys(this._boundEvents); if (!this._socket) { throw new Error( "[Crisp] emitAuthenticateSocket: cannot listen for events as socket " + "is not yet bound" ); } if (!auth.identifier || !auth.key) { throw new Error( "[Crisp] emitAuthenticateSocket: cannot listen for events as you " + "did not authenticate" ); } if (boundEvents.length === 0) { throw new Error( "[Crisp] emitAuthenticateSocket: cannot listen for events as no " + "event is being listened to" ); } this._socket.emit("authentication", { username : auth.identifier, password : auth.key, tier : auth.tier, events : boundEvents }); }, /** * Unstacks pending broker bind hooks * @memberof Crisp * @private * @method _unstackBrokerBindHooks * @param {object} modeInstance * @return {undefined} */ _unstackBrokerBindHooks : function(modeInstance) { // Setup user socket event listeners while (this._brokerBindHooks.length > 0) { this._brokerBindHooks.shift()( modeInstance, this._emitter ); } }, /** * Performs a request to REST API * @memberof Crisp * @private * @method _request * @param {string} resource * @param {string} method * @param {object} query * @param {object} body * @param {function} resolve * @param {function} reject * @return {undefined} */ _request : function(resource, method, query, body, resolve, reject) { var self = this; var requestParameters = { responseType : "json", timeout : Crisp.DEFAULT_REQUEST_TIMEOUT, headers : { "User-Agent" : self._useragent, "X-Crisp-Tier" : self.auth.tier }, throwHttpErrors : false }; // Add authorization? if (self.auth.token) { requestParameters.headers.Authorization = ("Basic " + self.auth.token); } // Add body? if (body) { requestParameters.json = body; } // Add query? if (query) { requestParameters.searchParams = query; } // Proceed request got[method](resource, requestParameters) .catch(function(error) { return Promise.resolve(error); }) .then(function(response, error) { var data = response.body; // Request error? if (!response.statusCode) { return reject({ reason : "error", message : "internal_error", code : 500, data : { namespace : "request", message : ( "Got request error: " + (response.name || "Unknown") ) } }); } // Response error? if (response.statusCode >= 400) { var reason_message = self._readErrorResponseReason( method, response.statusCode, response ); var data_message = ((response.body || {}).data || {}).message; return reject({ reason : "error", message : reason_message, code : response.statusCode, data : { namespace : "response", message : ( "Got response error: " + (data_message || reason_message) ) } }); } // Regular response return resolve( (response.body || {}).data || {} ); }); }, /** * Reads reason for error response * @memberof Crisp * @private * @method _readErrorResponseReason * @param {string} method * @param {number} statusCode * @param {object} response * @return {string} */ _readErrorResponseReason : function(method, statusCode, response) { // HEAD method? As HEAD requests do not expect any response body, then we \ // cannot map a reason from the response. if (method === "head") { // 5xx errors? if (statusCode >= 500) { return "server_error"; } // 4xx errors? if (statusCode >= 400) { return "route_error"; } } // Other methods must hold a response body, therefore we can fallback on \ // an HTTP error if we fail to acquire any reason at all. return ((response.body || {}).reason || "http_error"); }, /** * Verifies an event string and checks that signatures match * @memberof Crisp * @private * @method verifyHook * @param {string} secret * @param {object} body * @param {string} timestamp * @param {string} signature * @return {boolean} */ _verifySignature : function(secret, body, timestamp, signature) { // Ensure all provided data is valid if (!secret || !signature || !body || typeof body !== "object" || !timestamp || isNaN(timestamp) === true) { return false; } // Compute local trace var localTrace = ("[" + timestamp + ";" + JSON.stringify(body) + "]"); // Create local HMAC var localMac = Crypto.createHmac("sha256", secret); localMac.update(localTrace); // Compute local signature, and compare var localSignature = localMac.digest("hex"); return ( (signature === localSignature) ? true : false ); } }; module.exports = Crisp; module.exports.Crisp = Crisp;