crisp-api
Version:
Crisp API wrapper for Node - official, maintained by Crisp
795 lines (794 loc) • 29.3 kB
JavaScript
"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;