UNPKG

captivate-chat-api

Version:

This is a wrapper for captivate chat api socket custom channel

644 lines (573 loc) 22.3 kB
import { CaptivateChatFileManager, TtlDays } from './CaptivateChatFileManager'; import { captivateLogger } from './CaptivateChatAPI'; const ALLOWED_TTL_DAYS: TtlDays[] = [1, 7, 30, 90]; interface Action { id: string; data: any; } /** * Represents a conversation session, handling HTTP communication for sending and WebSocket for receiving. * Client-side sending uses HTTP, while server-side real-time communication uses WebSocket listeners. */ export class Conversation { public apiKey: string; private conversationId: string; private metadata: object; public fileManager: any; /** * WebSocket connection for receiving real-time messages from server. */ private socket: WebSocket; /** * Event listeners for real-time WebSocket communication. */ private listeners: Map<string, Function[]>; private mode: 'prod' | 'dev'; /** * Socket ID for HTTP requests. */ private socketId: string | null = null; /** * Default TTL (days) for file uploads in this conversation. Set via setTimeToLive(); used when uploading files. */ private defaultTtlDays: TtlDays | undefined; /** * Initializes a new Conversation instance. * @param conversationId - The unique identifier of the conversation. * @param socket - WebSocket instance for receiving real-time messages. * @param metadata - Optional metadata for the conversation. * @param apiKey - API key for HTTP communication (required). * @param mode - The mode of operation ('prod' or 'dev'). * @param socketId - Socket ID for HTTP requests. */ constructor(conversation_id: string, socket: WebSocket, metadata?: object, apiKey?: string, mode?: 'prod' | 'dev', socketId?: string | null) { this.apiKey = apiKey || ''; this.conversationId = conversation_id; this.socket = socket; this.metadata = metadata || {}; this.listeners = new Map(); this.mode = mode || 'prod'; // Default to 'prod' if not specified this.socketId = socketId || null; this.defaultTtlDays = undefined; // WebSocket listeners for real-time communication from server if (this.socket) { this.socket.onmessage = this.handleMessage.bind(this); } if (!this.apiKey) { console.warn('API key is required for HTTP communication. Some features may not work properly.'); } // Instantiate fileManager bound to this conversation context (uses this.defaultTtlDays for uploads) this.fileManager = { create: async (options: { file: File | Blob; fileName?: string; fileType?: string; storage?: boolean; url?: string; }): Promise<CaptivateChatFileManager> => { return CaptivateChatFileManager.create({ file: options.file, fileName: options.fileName, fileType: options.fileType, storage: options.storage, url: options.url, apiKey: this.apiKey, conversationId: this.conversationId, ttlDays: this.defaultTtlDays } as any); }, createFile: async (options: { file: File | Blob; fileName?: string; fileType?: string; storage?: boolean; url?: string; }): Promise<{ filename: string; type: string; file?: File | Blob; textContent: { type: 'file_content'; text: string; metadata: { source: 'file_attachment'; originalFileName: string; storageType?: 'direct'; }; }; }> => { return CaptivateChatFileManager.createFile({ file: options.file, fileName: options.fileName, fileType: options.fileType, storage: options.storage, url: options.url, apiKey: this.apiKey, conversationId: this.conversationId, ttlDays: this.defaultTtlDays } as any); }, createMultiple: async (options: { files: (File | Blob)[]; storage?: boolean; urls?: string[]; }): Promise<CaptivateChatFileManager> => { return CaptivateChatFileManager.createMultiple({ files: options.files, storage: options.storage, urls: options.urls, apiKey: this.apiKey, conversationId: this.conversationId, ttlDays: this.defaultTtlDays } as any); } }; } /** * Sets the default time-to-live (TTL) for file uploads in this conversation and stores it in metadata. * When files are uploaded via conversation.fileManager.create(), this value is sent as ttlDays (1, 7, 30, or 90). * Does not call any path-TTL API; the value is applied only at upload time. * @param days - Must be 1, 7, 30, or 90. Other values are ignored. */ public async setTimeToLive(days: number): Promise<void> { const d = days as TtlDays; if (ALLOWED_TTL_DAYS.includes(d)) { this.defaultTtlDays = d; await this.setMetadata({ timeToLive: days }); } } /** * Handles incoming WebSocket messages for real-time communication. * @param event - The WebSocket message event. */ private handleMessage(event: MessageEvent) { const message = JSON.parse(event.data); const eventType = message.event?.event_type; if (eventType && this.listeners.has(eventType)) { const payload = message.event.event_payload; this.listeners.get(eventType)?.forEach((callback) => callback(payload)); } } /** * Restarts WebSocket listeners for real-time communication. */ public restartListeners() { // Listen to WebSocket messages and handle events. this.socket.onmessage = this.handleMessage.bind(this); } /** * Adds an event listener for a specific event type. * @param eventType - The type of event to listen for. * @param callback - The function to invoke when the event occurs. */ private addListener(eventType: string, callback: Function): void { this.restartListeners(); if (!this.listeners.has(eventType)) { this.listeners.set(eventType, []); } this.listeners.get(eventType)?.push(callback); } /** * Registers a listener for messages from the bot or live chat. * @param callback - The function to handle incoming messages. */ public onMessage(callback: (message: string, type: string) => void): void { this.addListener('bot_message', (payload: any) => callback(payload.content, 'ai_agent')); this.addListener('livechat_message', (payload: any) => callback(payload.content, 'human_agent')); // Handle large messages that are too big for WebSocket usually from AI Agents this.addListener('general_error', async (payload: any) => { if (payload.error_code === 413 && payload.message_link) { try { const response = await fetch(payload.message_link, { method: 'GET', headers: { 'x-api-key': this.apiKey, 'Accept': 'application/json' } }); if (!response.ok) { throw new Error(`Failed to fetch large message: ${response.status} ${response.statusText}`); } const messageData = await response.json(); callback(messageData.botMessage.content, 'ai_agent'); } catch (error: any) { console.error('Error fetching large message:', error); callback(`[Error fetching large message: ${error.message}]`, 'error'); } } else { // Handle other general errors callback(`[Error: ${payload.error_desc || 'Unknown error'}]`, 'error'); } }); } /** * Registers a listener for receiving actions. * @param callback - The function to handle incoming action. */ public onActionReceived(callback: (actions: [Action]) => void): void { this.addListener('action', (payload: any) => callback(payload.actions)); } /** * Registers a listener for updates to the conversation. * @param callback - The function to handle conversation updates. */ public onConversationUpdate(callback: (update: any) => void): void { this.addListener('conversation_update', (payload: any) => { callback({ type: payload.type, conversationId: payload.conversation_id, data: payload.data, }); }); } /** * Registers a listener for error events. * @param callback - The function to handle errors. */ public onError(callback: (error: any) => void): void { this.addListener('general_error', (payload: any) => { callback({ conversationId: payload.conversation_id, errorCode: payload.error_code, errorDesc: payload.error_desc, }); }); } /** * Sends a message to the conversation. * @param content - The message content to send. * @returns A promise that resolves when the message is sent. */ public async sendMessage(content: object | string): Promise<void> { // Check if content is a string and transform it into a default object if (typeof content === 'string') { content = { type: 'text', text: content }; } return this.sendPayload('user_message', { type: 'message_create', client_msg_id: `unique-message-id-${Date.now()}`, conversation_id: this.conversationId, content, }); } /** * Sets metadata for the conversation and uses HTTP response for confirmation. * @param metadata - An object containing the metadata to set. * @returns A promise that resolves when the metadata update is successful. */ public async setMetadata(metadata: object): Promise<void> { if (typeof metadata !== 'object' || metadata === null) { throw new Error('Metadata must be a non-null object.'); } const metadataRequest = { action: 'sendMessage', event: { event_type: 'metadata', event_payload: { metadata, client_msg_id: `metadata-${Date.now()}`, conversation_id: this.conversationId, }, }, }; // Send the metadata update request via HTTP and get response const response = await this.sendPayloadViaHttp(metadataRequest); // The HTTP response confirms the metadata was set successfully captivateLogger.log('Metadata update confirmed via HTTP response:', response); } /** * Sets private metadata for the conversation (not visible to frontend). * @param privateMeta - An object containing the private metadata to set. * @returns A promise that resolves when the metadata update is successful. */ public async setPrivateMetadata(privateMeta: object): Promise<void> { if (typeof privateMeta !== 'object' || privateMeta === null) { throw new Error('Private metadata must be a non-null object.'); } // Reuse the setMetadata logic, but wrap in { private: ... } return this.setMetadata({ private: privateMeta }); } /** * Sends an action to the conversation. * @param actionId - The unique ID of the action to send. * @param data - Optional data associated with the action. * @returns A promise that resolves when the action is sent. */ public async sendAction(actionId: string, data: object = {}): Promise<void> { const response = await this.sendPayload('action', { type: 'normal', id: actionId, data, conversation_id: this.conversationId, }); // The HTTP response confirms the action was sent successfully captivateLogger.log('Action sent confirmed via HTTP response:', response); } /** * Requests the transcript of the conversation with automatic file URL refresh. * @returns A promise that resolves to the conversation transcript with refreshed file URLs. */ public async getTranscript(): Promise<object[]> { if (!this.apiKey) { throw new Error('API key is required to fetch transcript via REST.'); } const url = `${this.getBaseUrl()}/api/transcript?conversation_id=${encodeURIComponent(this.conversationId)}`; const response = await fetch(url, { method: 'GET', headers: { 'x-api-key': this.apiKey, 'Accept': 'application/json' } }); if (!response.ok) { throw new Error(`Failed to fetch transcript: ${response.status} ${response.statusText}`); } const data = await response.json(); // Refresh expired file URLs in the transcript const refreshedTranscript = await this.refreshExpiredFileUrls(data.transcript); return refreshedTranscript; } /** * Refreshes expired file URLs in the transcript. * @param transcript - The transcript array to process. * @returns A promise that resolves to the transcript with refreshed file URLs. */ private async refreshExpiredFileUrls(transcript: any[]): Promise<object[]> { const now = Math.floor(Date.now() / 1000); // Process all messages in parallel const refreshedMessages = await Promise.all( transcript.map(async (message) => { if (!message.files || message.files.length === 0) { return message; } // Process all files in parallel for this message const refreshedFiles = await Promise.all( message.files.map(async (file: any) => { if (!file.storage || !file.storage.fileKey) { return file; // No storage info, keep as is } try { const timeUntilExpiry = file.storage.expiresIn - now; if (timeUntilExpiry < 300) { // Less than 5 minutes // Refresh expired URL const refreshedUrl = await CaptivateChatFileManager.getSecureFileUrl( file.storage.fileKey, 7200 // 2 hours ); return { ...file, storage: { ...file.storage, presignedUrl: refreshedUrl, expiresIn: now + 7200 } }; } else { // URL is still valid return file; } } catch (error) { console.error(`Failed to refresh file ${file.filename}:`, error); return file; // Keep original file even if refresh failed } }) ); // Check if any files were refreshed const anyFileRefreshed = refreshedFiles.some((file, index) => file !== message.files[index] ); // If any file was refreshed, send an edit to update the message on the server if (anyFileRefreshed) { try { const messageId = message.message_id || message.id; if (messageId) { // Prefer existing content object if present; otherwise, construct minimal content const updatedContent = message.content ? { ...message.content, files: refreshedFiles } : { text: message.text || '', files: refreshedFiles }; await this.editMessage(String(messageId), updatedContent); } } catch (err) { console.error('Failed to push refreshed URLs via editMessage:', err); } } return { ...message, files: refreshedFiles }; }) ); return refreshedMessages; } /** * Requests metadata for the conversation using HTTP request with direct response. * @returns A promise that resolves to the conversation metadata. */ public async getMetadata(): Promise<object> { const metadataRequest = { action: 'sendMessage', event: { event_type: 'metadata_request', event_payload: { conversation_id: this.conversationId, }, }, }; // Send the metadata request via HTTP and get response const response = await this.sendPayloadViaHttp(metadataRequest); // The HTTP response contains the metadata captivateLogger.log('Metadata retrieved via HTTP response:', response); return response.metadata || response; } /** * Deletes this conversation using HTTP request with direct response. * @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 the conversation is deleted successfully. */ public async delete(options: { softDelete?: boolean } = {}): Promise<void> { const { softDelete = true } = options; const deleteRequest = { action: 'sendMessage', event: { event_type: 'delete_conversation', event_payload: { conversation_id: this.conversationId, softdelete: softDelete }, }, }; // Send the delete request via HTTP and get response const response = await this.sendPayloadViaHttp(deleteRequest); // The HTTP response confirms the conversation was deleted successfully captivateLogger.log(`Conversation ${softDelete ? 'soft' : 'hard'} delete confirmed via HTTP response:`, response); } /** * Edits a message in the conversation using HTTP request with direct response. * @param messageId - The ID of the message to edit. * @param content - The new content for the message (object or string). * @returns A promise that resolves when the edit is confirmed via HTTP response. */ public async editMessage(messageId: string, content: object | string): Promise<void> { // If content is a string, wrap it in a default object if (typeof content === 'string') { content = { type: 'text', text: content }; } const editRequest = { action: 'sendMessage', event: { event_type: 'edit_message', event_payload: { type: 'message_create', client_msg_id: `edit-message-id-${Date.now()}`, conversation_id: this.conversationId, message_id: messageId, content, }, }, }; // Send the edit request via HTTP and get response const response = await this.sendPayloadViaHttp(editRequest); // The HTTP response confirms the message was edited successfully captivateLogger.log('Message edit confirmed via HTTP response:', response); } /** * Sends a payload via HTTP API (primary method). * @param eventType - The type of event being sent. * @param payload - The payload data to include with the event. * @returns A promise that resolves to the response data. */ private async sendPayload(eventType: string, payload: object): Promise<any> { const message = { action: 'sendMessage', event: { event_type: eventType, event_payload: payload, }, }; return this.sendPayloadViaHttp(message); } /** * Sends a payload via HTTP API (primary communication method). * @param message - The message to send via HTTP. * @returns A promise that resolves to the response data. */ private async sendPayloadViaHttp(message: object): Promise<any> { if (!this.apiKey) { throw new Error('API key is required for HTTP communication'); } const url = `${this.getBaseUrl()}/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 }; } captivateLogger.log('Payload received from server via HTTP:', responseData); return responseData; } catch (error) { console.error('HTTP request failed:', error); throw new Error(`Failed to send payload via HTTP: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Removes a specific event listener. * @param eventType - The type of event. * @param callback - The callback function to remove. */ private removeListener(eventType: string, callback: Function): void { const callbacks = this.listeners.get(eventType); if (callbacks) { this.listeners.set( eventType, callbacks.filter((cb) => cb !== callback) ); } } /** * Gets the base URL for API requests based on the current mode. * @returns The base URL for the API. */ private getBaseUrl(): string { return this.mode === 'prod' ? 'https://channel.prod.captivat.io' : 'https://channel.dev.captivat.io'; } /** * Gets the unique identifier of the conversation. * @returns The conversation ID. */ public getConversationId(): string { return this.conversationId; } }