captivate-chat-api
Version:
This is a wrapper for captivate chat api socket custom channel
644 lines (573 loc) • 22.3 kB
text/typescript
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;
}
}