UNPKG

crisp-api

Version:

Crisp API wrapper for Node - official, maintained by Crisp

795 lines (794 loc) 29.3 kB
"use strict"; /* * This file is part of node-crisp-api * * Copyright (c) 2025 Crisp IM SAS * All rights belong to Crisp IM SAS */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __exportStar = (this && this.__exportStar) || function(m, exports) { for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Crisp = void 0; /************************************************************************** * IMPORTS ***************************************************************************/ // NPM const url_1 = require("url"); const crypto_1 = __importDefault(require("crypto")); const got_1 = __importDefault(require("got")); const socket_io_client_1 = require("socket.io-client"); const mitt_1 = __importDefault(require("mitt")); // PROJECT: SERVICES const bucket_1 = __importDefault(require("./services/bucket")); const media_1 = __importDefault(require("./services/media")); const plugin_1 = __importDefault(require("./services/plugin")); const website_1 = __importDefault(require("./services/website")); /************************************************************************** * CONSTANTS ***************************************************************************/ const AVAILABLE_RTM_MODES = [ "websockets", "webhooks" ]; const VERSION = "10.2.0"; // Base configuration const DEFAULT_REQUEST_TIMEOUT = 10000; const DEFAULT_SOCKET_TIMEOUT = 10000; const DEFAULT_SOCKET_RECONNECT_DELAY = 5000; const DEFAULT_SOCKET_RECONNECT_DELAY_MAX = 10000; const DEFAULT_SOCKET_RECONNECT_FACTOR = 0.75; const DEFAULT_BROKER_SCHEDULE = 500; const DEFAULT_EVENT_REBIND_INTERVAL_MIN = 2500; const DEFAULT_USERAGENT_PREFIX = "node-crisp-api/"; // REST API defaults const DEFAULT_REST_HOST = "https://api.crisp.chat"; const DEFAULT_REST_BASE_PATH = "/v1/"; // RTM API defaults const DEFAULT_RTM_MODE = "websockets"; const 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 const services = { Bucket: bucket_1.default, Media: media_1.default, Plugin: plugin_1.default, Website: website_1.default }; /************************************************************************** * CLASSES ***************************************************************************/ /** * Crisp */ class Crisp { /** * Constructor */ constructor() { this.bucket = new bucket_1.default(); this.media = new media_1.default(); this.plugin = new plugin_1.default(); this.website = new website_1.default(); this.auth = { tier: "user", identifier: null, key: null, token: null }; this._rest = { host: DEFAULT_REST_HOST, basePath: DEFAULT_REST_BASE_PATH }; this._rtm = { host: "", mode: DEFAULT_RTM_MODE }; this._useragent = (DEFAULT_USERAGENT_PREFIX + VERSION); this._customHeaders = {}; this._emitter = (0, mitt_1.default)(); this._socket = null; this._loopback = null; this._lastEventRebind = null; this._brokerScheduler = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any, no-unused-vars this._brokerBindHooks = []; this._boundEvents = {}; this._prepareServices(); } /** * Sets the REST API host */ setRestHost(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 */ setRtmHost(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) */ setRtmMode(mode) { if (AVAILABLE_RTM_MODES.indexOf(mode) !== -1) { this._rtm.mode = mode; } else { throw new Error("[Crisp] setRtmMode: parameter mode value should be one of: " + AVAILABLE_RTM_MODES.join(", ")); } } /** * Sets custom headers to be included in all API requests */ setCustomHeaders(headers) { if (typeof headers === "object" && headers !== null) { this._customHeaders = headers; } else { throw new Error("[Crisp] setCustomHeaders: parameter headers should be an object"); } } /** * Sets the authentication tier */ setTier(tier) { this.auth.tier = (tier || "user"); } /** * Authenticates */ authenticate(identifier, key) { // Store credentials this.auth.identifier = identifier; this.auth.key = key; // Assign pre-computed authentication token this.auth.token = Buffer.from(identifier + ":" + key).toString("base64"); } /** * Authenticates (with tier) */ authenticateTier(tier, identifier, key) { this.setTier(tier); this.authenticate(identifier, key); } /* eslint-disable @typescript-eslint/no-explicit-any */ /** * Method wrapper to HEAD a resource */ head(resource, query) { return new Promise((resolve, reject) => { this.__request(resource, "head", (query || {}), null, resolve, reject); }); } /** * Method wrapper to GET a resource */ get(resource, query) { return new Promise((resolve, reject) => { this.__request(resource, "get", (query || {}), null, resolve, reject); }); } /** * Method wrapper to POST a resource */ post(resource, query, body) { return new Promise((resolve, reject) => { this.__request(resource, "post", (query || {}), (body || {}), resolve, reject); }); } /** * Method wrapper to PATCH a resource */ patch(resource, query, body) { return new Promise((resolve, reject) => { this.__request(resource, "patch", (query || {}), (body || {}), resolve, reject); }); } /** * Method wrapper to PUT a resource */ put(resource, query, body) { return new Promise((resolve, reject) => { this.__request(resource, "put", (query || {}), (body || {}), resolve, reject); }); } /** * Method wrapper to DELETE a resource */ delete(resource, query, body) { return new Promise((resolve, reject) => { this.__request(resource, "delete", (query || {}), (body || null), resolve, reject); }); } /* eslint-enable @typescript-eslint/no-explicit-any */ /** * Binds RTM event */ // eslint-disable-next-line @typescript-eslint/no-explicit-any, no-unused-vars on(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 (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.on(event, callback); // Subscribe event on the broker if (this._boundEvents[event] !== true) { let rtmMode = this._rtm.mode; // Mark event as bound this._boundEvents[event] = true; // Broker not connected? Connect now. return this.__prepareBroker((instance, emitter) => { // Listen for event? (once instance is bound) switch (rtmMode) { case "websockets": { // Listen on socket event instance.on(event, (data) => { emitter.emit(event, data); }); break; } } }); } return Promise.resolve(); } /** * Receives a raw event and dispatches it to the listener (used for Web Hooks) */ receiveHook(body) { if (this._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 (this._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(() => { this._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) */ verifyHook(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) */ verifyWidget(secret, body, timestamp, signature) { return this.__verifySignature(secret, body, timestamp, signature); } /** * Rebinds socket events (used for WebSockets) */ rebindSocket() { if (!this._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) const nowTime = Date.now(); if (this._lastEventRebind !== null && ((nowTime - this._lastEventRebind) < DEFAULT_EVENT_REBIND_INTERVAL_MIN)) { throw new Error("[Crisp] rebindSocket: cannot rebind, last rebind was requested too " + "recently"); } return Promise.resolve() .then(() => { // Rebind to socket events (eg. newly bound websites) this._lastEventRebind = nowTime; this._socket.emit("socket:bind", {}); return Promise.resolve(); }); } /** * Prepares a URI based from path segments */ prepareRestUrl(paths) { if (Array.isArray(paths) === true) { let 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 */ _prepareServices() { // Bind services for (const name in services) { const serviceInstance = new services[name](); // Acquire service map const serviceMap = this[(name[0].toLowerCase() + name.substring(1))]; // 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 */ // eslint-disable-next-line @typescript-eslint/no-explicit-any __prepareResources(serviceMap, resources) { for (let i = 0; i < resources.length; i++) { const resourceConstructor = resources[i]; const resourceInstance = new resourceConstructor(this); // Bind each method of the resource instance to the service map const methodNames = Object.getOwnPropertyNames(Object.getPrototypeOf(resourceInstance)); for (const methodName of methodNames) { if (methodName !== "constructor") { serviceMap[methodName] = resourceInstance[methodName].bind(resourceInstance); } } } } /** * Binds broker to the main object */ __prepareBroker( // eslint-disable-next-line @typescript-eslint/no-explicit-any, no-unused-vars fnBindHook) { return new Promise((resolve, reject) => { const rtmMode = this._rtm.mode; const rtmHostOverride = this._rtm.host; // Append bind hook to pending stack this._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 (this._brokerScheduler === null) { // Socket or loopback already set? We should not even have entered \ // there. if (this._socket || this._loopback) { throw new Error("[Crisp] prepareBroker: illegal call to prepare broker (tie break)"); } // @ts-ignore this._brokerScheduler = setTimeout(() => { switch (rtmMode) { case "websockets": { // Connect to socket now // Notice: will unstack broker bind hooks once ready this.__connectSocket(rtmHostOverride) .then(resolve) .catch(reject); break; } case "webhooks": { // Connect to loopback now this.__connectLoopback() .then(resolve) .catch(reject); break; } default: { const unsupportedError = new Error("[Crisp] prepareBroker: mode of RTM broker unsupported " + "('" + rtmMode + "')"); reject(unsupportedError); } } }, DEFAULT_BROKER_SCHEDULE); } else { // Pass-through resolve(true); } }); } /** * Connects loopback (used for Web Hooks) */ __connectLoopback() { return Promise.resolve() .then(() => { // Assign emitter to loopback this._loopback = this._emitter; // Unstack broker bind hooks immediately this.__unstackBrokerBindHooks(this._loopback); return Promise.resolve(); }); } /** * Connects socket, using preferred RTM API host (used for WebSockets) */ __connectSocket(rtmHostOverride) { return Promise.resolve() .then(() => { // Any override RTM API host? if (rtmHostOverride) { return Promise.resolve({ socket: { app: rtmHostOverride } }); } // Acquire RTM API URL from remote let restUrlSegments; switch (this.auth.tier) { case "plugin": { restUrlSegments = ["plugin", "connect", "endpoints"]; break; } default: { restUrlSegments = ["user", "connect", "endpoints"]; } } return this.get(this.prepareRestUrl(restUrlSegments)) .catch(() => { // Void error (consider as empty response) return Promise.resolve({}); }); }) .then((endpoints) => { var _a, _b, _c; // @ts-ignore const rtmHostAffinity = (((_a = endpoints === null || endpoints === void 0 ? void 0 : endpoints.socket) === null || _a === void 0 ? void 0 : _a.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 const rtmHostUrl = new url_1.URL(rtmHostAffinity); // Connect to socket // @ts-ignore this._socket = (0, socket_io_client_1.io)(rtmHostUrl.origin, { path: (rtmHostUrl.pathname || "/"), transports: ["websocket"], timeout: DEFAULT_SOCKET_TIMEOUT, reconnection: true, reconnectionDelay: DEFAULT_SOCKET_RECONNECT_DELAY, reconnectionDelayMax: DEFAULT_SOCKET_RECONNECT_DELAY_MAX, randomizationFactor: DEFAULT_SOCKET_RECONNECT_FACTOR }); this.__emitAuthenticateSocket(); // Setup base socket event listeners (_b = this._socket) === null || _b === void 0 ? void 0 : _b.io.on("reconnect", () => { this.__emitAuthenticateSocket(); }); (_c = this._socket) === null || _c === void 0 ? void 0 : _c.on("unauthorized", () => { throw new Error("[Crisp] connectSocket: cannot listen for events as " + "authentication is invalid"); }); // Setup user socket event listeners this.__unstackBrokerBindHooks(this._socket); return Promise.resolve(); }); } /** * Authenticates client (used for WebSockets) */ __emitAuthenticateSocket() { const auth = this.auth; const 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 */ // eslint-disable-next-line @typescript-eslint/no-explicit-any __unstackBrokerBindHooks(modeInstance) { var _a; // Setup user socket event listeners while (this._brokerBindHooks.length > 0) { (_a = this._brokerBindHooks.shift()) === null || _a === void 0 ? void 0 : _a(modeInstance, this._emitter); } } /** * Performs a request to REST API */ __request(resource, method, query, body, // eslint-disable-next-line no-unused-vars resolve, // eslint-disable-next-line no-unused-vars reject) { let requestParameters = { responseType: "json", timeout: DEFAULT_REQUEST_TIMEOUT, headers: Object.assign({ "User-Agent": this._useragent, "X-Crisp-Tier": this.auth.tier }, this._customHeaders), throwHttpErrors: false }; // Add authorization? if (this.auth.token) { // @ts-ignore requestParameters.headers.Authorization = ("Basic " + this.auth.token); } // Add body? if (body) { // @ts-ignore requestParameters.json = body; } // Add query? if (query) { const params = new URLSearchParams(); Object.entries(query).forEach(([key, value]) => { if (value === null || value === undefined) { return; } if (typeof value === "object") { params.append(key, JSON.stringify(value)); } else { params.append(key, String(value)); } }); // @ts-ignore requestParameters.searchParams = params; } // Proceed request got_1.default[method](resource, requestParameters) .catch((error) => { return Promise.resolve(error); }) .then((response) => { var _a, _b, _c; // 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) { let reasonMessage = this.__readErrorResponseReason(method, response.statusCode, response); const dataMessage = (((_b = (_a = response === null || response === void 0 ? void 0 : response.body) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b.message) || ""); return reject({ reason: "error", message: reasonMessage, code: response.statusCode, data: { namespace: "response", message: ("Got response error: " + (dataMessage || reasonMessage)) } }); } // Regular response return resolve((((_c = response === null || response === void 0 ? void 0 : response.body) === null || _c === void 0 ? void 0 : _c.data) || {})); }); } /** * Reads reason for error response */ __readErrorResponseReason(method, statusCode, response) { var _a; // 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. // @ts-ignore return ((((_a = response === null || response === void 0 ? void 0 : response.body) === null || _a === void 0 ? void 0 : _a.reason) || "http_error")); } /** * Verifies an event string and checks that signatures match */ __verifySignature(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 let localTrace = ("[" + timestamp + ";" + JSON.stringify(body) + "]"); // Create local HMAC let localMac = crypto_1.default.createHmac("sha256", secret); localMac.update(localTrace); // Compute local signature, and compare let localSignature = localMac.digest("hex"); return ((signature === localSignature) ? true : false); } } exports.Crisp = Crisp; /** * @deprecated Use import { RTM_MODES } instead */ Crisp.RTM_MODES = { WebSockets: "websockets", WebHooks: "webhooks" }; ; /************************************************************************** * EXPORTS ***************************************************************************/ __exportStar(require("./resources"), exports); exports.default = Crisp;