UNPKG

captivate-chat-api

Version:

This is a wrapper for captivate chat api socket custom channel

664 lines (588 loc) 23.8 kB
// Import the Conversation class import { Conversation } from './Conversation'; // Determine the WebSocket implementation based on the environment (browser, Node.js, or React Native) let WebSocketImpl: typeof WebSocket; // Browser environment (web) if (typeof window !== 'undefined' && window.WebSocket) { WebSocketImpl = window.WebSocket; } // React Native environment else if (typeof global !== 'undefined' && global.WebSocket) { WebSocketImpl = global.WebSocket; } // Node.js environment else { try { WebSocketImpl = require('ws'); } catch (error) { throw new Error('WebSocket not available in this environment and ws module could not be loaded'); } } // Removed unused `waitForSocketOpen` function to improve maintainability. // Proxy guard function for automatic WebSocket state checking and reconnection function withSocketGuard<T extends object>(instance: T): T { return new Proxy(instance, { get(target, prop, receiver) { const orig = target[prop as keyof T]; if ( typeof orig === 'function' && !['isSocketActive', 'connect', 'reconnect', 'getSocket', 'getConversationId'].includes(prop as string) ) { return async function (...args: any[]) { if (typeof (target as any)['isSocketActive'] === 'function' && !(target as any)['isSocketActive']()) { // Socket not active, attempting to reconnect try { if (typeof (target as any)['reconnect'] === 'function') { await (target as any)['reconnect'](); } else { throw new Error('Reconnect method not available'); } } catch (error: any) { throw new Error(`Socket reconnection failed. Cannot execute ${String(prop)}: ${error.message}`); } } return (orig as Function).apply(target, args); }; } return orig; } }); } /** * CaptivateChatAPI class for managing conversations through HTTP connections for sending and WebSocket for receiving. * Client-side sending uses HTTP, while server-side real-time communication uses WebSocket listeners. */ // Global debug flag for CaptivateChatAPI only let captivateDebugMode = false; // Store original console methods const originalConsole = { log: console.log, warn: console.warn, error: console.error }; // Custom debug logger that only affects CaptivateChatAPI logs export const captivateLogger = { log: (...args: any[]) => { if (captivateDebugMode) { originalConsole.log('[CaptivateChatAPI]', ...args); } }, warn: (...args: any[]) => { originalConsole.warn('[CaptivateChatAPI]', ...args); }, error: (...args: any[]) => { originalConsole.error('[CaptivateChatAPI]', ...args); } }; export class CaptivateChatAPI { /** * Static registry to track instances by API key and mode. * Enforces singleton pattern: one instance per unique (apiKey, mode) combination. */ private static instances: Map<string, CaptivateChatAPI> = new Map(); /** * Generates a unique instance key for the registry. * @param apiKey - The API key. * @param mode - The mode ('prod' or 'dev'). * @returns A composite key string. */ private static getInstanceKey(apiKey: string, mode: 'prod' | 'dev'): string { return `${apiKey}-${mode}`; } private apiKey!: string; private mode!: 'prod' | 'dev'; /** * WebSocket URL for real-time communication. */ private url!: string; /** * WebSocket connection for receiving real-time messages. */ private socket!: InstanceType<typeof WebSocketImpl> | null; private conversations!: Map<string, Conversation>; /** * Socket ID received from socket_connected event. */ private socketId: string | null = null; /** * WebSocket reconnection attempts counter. */ private reconnectAttempts: number = 0; /** * Maximum number of reconnection attempts. */ private maxReconnectAttempts: number = 5; /** * Delay between reconnection attempts. */ private reconnectDelay: number = 3000; /** * Sets the debug mode for CaptivateChatAPI logging. * @param enabled - Whether to enable debug logging for CaptivateChatAPI. */ static setDebugMode(enabled: boolean): void { captivateDebugMode = enabled; } /** * Gets the current debug mode state for CaptivateChatAPI. * @returns True if debug mode is enabled, false otherwise. */ static getDebugMode(): boolean { return captivateDebugMode; } /** * Creates an instance of CaptivateChatAPI. * Enforces singleton pattern: returns existing instance if one exists for the given API key and mode. * @param apiKey - The API key for authentication. * @param mode - The mode of operation ('prod' for production or 'dev' for development). * @returns Either a new instance or the existing singleton instance for this API key/mode combination. */ constructor(apiKey: string, mode: 'prod' | 'dev' = 'prod') { const instanceKey = CaptivateChatAPI.getInstanceKey(apiKey, mode); // Check if instance already exists (singleton enforcement) const existingInstance = CaptivateChatAPI.instances.get(instanceKey); if (existingInstance) { captivateLogger.log(`Reusing existing CaptivateChatAPI instance for ${instanceKey}`); return existingInstance; } // Initialize new instance this.apiKey = apiKey; this.mode = mode; // Legacy WebSocket URL (deprecated) this.url = this.mode === 'prod' ? `wss://channel.wss.captivatechat.ai/dev?apiKey=${apiKey}` : `wss://channel-dev.wss.captivatechat.ai/dev?apiKey=${apiKey}`; this.socket = null; this.conversations = new Map(); // Store in registry CaptivateChatAPI.instances.set(instanceKey, this); captivateLogger.log(`Created new CaptivateChatAPI instance for ${instanceKey}`); captivateLogger.log('CaptivateChatAPI: Using HTTP for sending messages and WebSocket for receiving real-time updates.'); } /** * Sends a message via HTTP API. * @param message - The message object to send. * @returns A promise that resolves to the response data. */ private async _send(message: object): Promise<any> { if (!this.apiKey) { throw new Error('API key is required for HTTP communication'); } const baseUrl = this.mode === 'prod' ? 'https://channel.prod.captivat.io' : 'https://channel.dev.captivat.io'; const url = `${baseUrl}/api/custom-channel/sockets/message`; // Add socket_id to the event if available const messageWithSocketId = { ...message, event: { ...(message as any).event, socket_id: this.socketId } }; try { const response = await fetch(url, { method: 'POST', headers: { 'x-api-key': this.apiKey, 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(messageWithSocketId) }); if (!response.ok) { throw new Error(`HTTP request failed: ${response.status} ${response.statusText}`); } // Check if response is JSON or plain text const contentType = response.headers.get('content-type'); let responseData; if (contentType && contentType.includes('application/json')) { responseData = await response.json(); } else { // Handle plain text response (like "OK") const textResponse = await response.text(); captivateLogger.log('Server returned plain text:', textResponse); // Return a mock response structure for plain text responses responseData = { status: 'success', message: textResponse, event: { event_payload: { conversation_id: null // Will be handled by WebSocket } } }; } captivateLogger.log('Message received from server via HTTP:', responseData); return responseData; } catch (error) { console.error('HTTP request failed:', error); throw new Error(`Failed to send message via HTTP: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Connects to the WebSocket server for real-time communication. * @returns A promise that resolves once the connection is successfully established. */ public async connect(): Promise<void> { return new Promise((resolve, reject) => { try { this.socket = new WebSocketImpl(this.url); const timeoutId = setTimeout(() => { reject(new Error('Connection timeout: socket_connected not received')); }, 10000); this.socket.onopen = () => { // WebSocket connected, waiting for API confirmation }; this.socket.onmessage = (event: MessageEvent) => { try { const message = JSON.parse(event.data.toString()); if (message.event?.event_type === 'socket_connected') { // Capture socket_id from the event payload this.socketId = message.event.event_payload?.socket_id || null; captivateLogger.log('Socket connected with ID:', this.socketId); // API Successfully Connected clearTimeout(timeoutId); resolve(); } } catch (err) { console.error('Error parsing message:', err); } }; this.socket.onerror = (event: any) => { console.error('WebSocket Error:', event.message || event); clearTimeout(timeoutId); reject(new Error(event.message || 'WebSocket error')); }; this.socket.onclose = (event) => { // WebSocket connection closed if (event.code !== 1000) { // Attempting to reconnect setTimeout(() => this.connect(), 3000); // Reconnect after 3 seconds } }; } catch (error) { reject(error); } }); } /** * Creates a new conversation or starts a bot-first/user-first conversation. * @param userId - The unique identifier for the user. * @param userBasicInfo - Basic information about the user (e.g., name, email). * @param userData - Additional optional user data. * @param autoConversationStart - Mode to auto-start the conversation ('bot-first' or 'user-first'). * @returns A promise that resolves to the created Conversation instance. */ public async createConversation( userId: string, userBasicInfo: object = {}, metadata: Record<string, any> = {}, autoConversationStart: 'bot-first' | 'user-first' = 'bot-first', privateMetadata: object = {} ): Promise<Conversation> { return new Promise(async (resolve, reject) => { try { // Merge privateMetadata into metadata under the 'private' key if provided const fullMetadata: Record<string, any> = { ...metadata }; if (privateMetadata) { fullMetadata.private = privateMetadata; } // Listen for conversation_start_success from WebSocket to get conversation_id // Set up listener BEFORE sending the HTTP request to avoid race conditions const onMessage = (event: MessageEvent) => { try { captivateLogger.log('messagereceived:', event.data.toString()); const message = JSON.parse(event.data.toString()); if (message.event?.event_type === 'conversation_start_success') { const conversationId = message.event.event_payload?.conversation_id; if (!conversationId) { this.socket?.removeEventListener('message', onMessage); reject(new Error('No conversation_id received in WebSocket response')); return; } this.socket?.removeEventListener('message', onMessage); const conversation = withSocketGuard(new Conversation(conversationId, this.socket!, {}, this.apiKey, this.mode, this.socketId)); this.conversations.set(conversationId, conversation); if (autoConversationStart === 'bot-first') { conversation.sendMessage({ type: 'text', text: '' }) .then(() => resolve(conversation)) .catch(reject); } else { resolve(conversation); } } } catch (err) { console.error('Error processing message:', err); } }; this.socket?.addEventListener('message', onMessage); // Timeout if no WebSocket confirmation received setTimeout(() => { this.socket?.removeEventListener('message', onMessage); reject(new Error('Timeout: No conversation_start_success received from WebSocket')); }, 10000); // Send HTTP request AFTER setting up the listener await this._send({ action: 'sendMessage', event: { event_type: 'conversation_start', event_payload: { userId, userBasicInfo, metadata: fullMetadata }, }, }); } catch (error) { reject(error); } }); } /** * Retrieves an existing conversation by its ID or creates a new one if not found. * @param conversationId - The unique ID of the conversation. * @returns The Conversation instance associated with the given ID. */ getConversation(conversationId: string): Conversation | void { let conversation = this.conversations.get(conversationId); if (!conversation) { // If conversation is not found, check if socket is initialized if (this.socket !== null) { // If socket is initialized, create the conversation conversation = withSocketGuard(new Conversation(conversationId, this.socket,{},this.apiKey, this.mode, this.socketId)); this.conversations.set(conversationId, conversation); } else { // Handle the case where socket is not initialized console.error('Socket is not initialized'); throw new Error('WebSocket connection not established'); } } conversation?.restartListeners(); return conversation; } /** * Retrieves user conversations. Uses v2 if filter, search, or pagination is provided, otherwise uses v1. * Supports both legacy API (userId string) and new API (options object) for backward compatibility. * @param userIdOrOptions - Either a userId string (legacy) or options object containing userId and optional filter, search, and pagination. * @returns A promise resolving to an object with a list of Conversation instances and optional pagination data. */ public async getUserConversations( userIdOrOptions: string | { userId: string; filter?: object; search?: object; pagination?: { page?: string | number; limit?: string | number }; apiKeys?: string[]; // Restore multi-api-key support } ): Promise<{ conversations: Conversation[]; pagination?: { hasNextPage: boolean; hasPrevPage: boolean; page: number; pageSize: number; total: number; totalPages: number; } }> { // Handle backward compatibility - if string is passed, treat as userId const options = typeof userIdOrOptions === 'string' ? { userId: userIdOrOptions } : userIdOrOptions; const { userId, filter = {}, search = {}, pagination = {}, apiKeys } = options; const conversations: Conversation[] = []; let paginationData: { hasNextPage: boolean; hasPrevPage: boolean; page: number; pageSize: number; total: number; totalPages: number; } | undefined = undefined; const useV2 = (filter && Object.keys(filter).length > 0) || (search && Object.keys(search).length > 0) || (pagination && Object.keys(pagination).length > 0) || (apiKeys && Array.isArray(apiKeys) && apiKeys.length > 0); try { // Build request payload for HTTP let httpResponse: any; if (useV2) { const eventPayload: any = { userId }; if (filter && Object.keys(filter).length > 0) { eventPayload.filter = filter; } if (search && Object.keys(search).length > 0) { eventPayload.search = search; } if (pagination && Object.keys(pagination).length > 0) { eventPayload.pagination = pagination; } if (apiKeys && Array.isArray(apiKeys) && apiKeys.length > 0) { eventPayload.apiKeys = apiKeys; } httpResponse = await this._send({ action: 'sendMessage', event: { event_type: 'get_user_conversations_v2', event_payload: eventPayload, }, }); } else { httpResponse = await this._send({ action: 'sendMessage', event: { event_type: 'get_user_conversations', event_payload: { userId, }, }, }); } // Support both direct JSON responses and event-wrapped responses const payload = httpResponse?.event?.event_payload || httpResponse; const responseConversations = payload?.conversations || []; const responsePagination = payload?.pagination; for (const conv of responseConversations) { const { conversation_id, metadata, apiKey } = conv; if (this.socket !== null) { conversations.push( withSocketGuard( new Conversation( conversation_id, this.socket, metadata, apiKey || this.apiKey, this.mode, this.socketId ) ) ); } } if (responsePagination) { paginationData = responsePagination; } return { conversations, pagination: paginationData }; } catch (error) { throw error; } } /** * Deletes all conversations for a specific user using HTTP request with direct response. * @param userId - The unique identifier for the user whose conversations will be deleted. * @param options - Delete options object with softDelete property. * @param options.softDelete - Whether to perform a soft delete (true) or hard delete (false). Defaults to true. * @returns A promise that resolves when all conversations are successfully deleted. */ public async deleteUserConversations(userId: string, options: { softDelete?: boolean } = {}): Promise<void> { if (!userId) { throw new Error('User ID must be provided.'); } const { softDelete = true } = options; const deleteRequest = { action: 'sendMessage', event: { event_type: 'delete_user_conversations', event_payload: { userId: userId, softdelete: softDelete }, }, }; // Send the delete request via HTTP and get response const response = await this._send(deleteRequest); // The HTTP response confirms the conversations were deleted successfully captivateLogger.log(`User conversations ${softDelete ? 'soft' : 'hard'} delete confirmed via HTTP response:`, response); } /** * Gets the WebSocket instance for real-time communication. */ public getSocket() { return this.socket; } /** * Gets the socket ID for HTTP requests. */ public getSocketId(): string | null { return this.socketId; } /** * Checks if the WebSocket connection is active and open for real-time communication. * @returns True if the socket is open, false otherwise. */ public isSocketActive(): boolean { return !!this.socket && this.socket.readyState === WebSocketImpl.OPEN; } /** * Attempts to reconnect to the WebSocket server for real-time communication. * @returns A promise that resolves when reconnection is successful. */ public async reconnect(): Promise<void> { if (this.reconnectAttempts >= this.maxReconnectAttempts) { throw new Error(`Max reconnection attempts (${this.maxReconnectAttempts}) reached`); } this.reconnectAttempts++; // Attempting to reconnect try { await this.connect(); this.reconnectAttempts = 0; // Reset on successful connection // Reconnection successful } catch (error) { console.error(`Reconnection attempt ${this.reconnectAttempts} failed:`, error); if (this.reconnectAttempts < this.maxReconnectAttempts) { // Wait before next attempt await new Promise(resolve => setTimeout(resolve, this.reconnectDelay)); return this.reconnect(); } else { throw error; } } } /** * Static factory method to create and connect a CaptivateChatAPI instance. * Enforces singleton pattern: constructor returns existing instance if one exists. * The returned instance is automatically guarded: all method calls will check socket state and auto-reconnect if needed. * @param apiKey - The API key for authentication. * @param mode - The mode of operation ('prod' or 'dev'). * @returns A promise that resolves to a connected and guarded CaptivateChatAPI instance. */ static async create(apiKey: string, mode: 'prod' | 'dev' = 'prod'): Promise<CaptivateChatAPI> { const api = new CaptivateChatAPI(apiKey, mode); // Constructor handles singleton // Only connect if socket is not already active if (!api.isSocketActive()) { await api.connect(); } return withSocketGuard(api); } /** * Gets an existing instance for the given API key and mode, if one exists. * @param apiKey - The API key. * @param mode - The mode ('prod' or 'dev'). * @returns The existing instance, or undefined if none exists. */ static getInstance(apiKey: string, mode: 'prod' | 'dev' = 'prod'): CaptivateChatAPI | undefined { return this.instances.get(this.getInstanceKey(apiKey, mode)); } /** * Checks if an instance exists for the given API key and mode. * @param apiKey - The API key. * @param mode - The mode ('prod' or 'dev'). * @returns True if an instance exists, false otherwise. */ static hasInstance(apiKey: string, mode: 'prod' | 'dev' = 'prod'): boolean { return this.instances.has(this.getInstanceKey(apiKey, mode)); } /** * Disposes of this instance, closing the WebSocket and removing it from the registry. * After calling dispose(), a new instance can be created for the same API key/mode combination. */ public dispose(): void { const key = CaptivateChatAPI.getInstanceKey(this.apiKey, this.mode); // Close WebSocket connection if (this.socket) { this.socket.close(1000, 'Instance disposed'); this.socket = null; } // Clear conversations this.conversations.clear(); // Remove from registry CaptivateChatAPI.instances.delete(key); captivateLogger.log(`Disposed CaptivateChatAPI instance for ${key}`); } }