@aslaluroba/help-center
Version:
A powerful and customizable help center widget for Angular applications with real-time chat functionality, AI assistance, multi-language support, and user feedback collection.
1 lines • 385 kB
Source Map (JSON)
{"version":3,"file":"aslaluroba-help-center.mjs","sources":["../../../src/app/services/action-handler.service.ts","../../../src/app/services/api.service.ts","../../../src/app/services/ably.service.ts","../../../src/app/services/chat-session.service.ts","../../../src/app/services/translation.service.ts","../../../src/app/language.service.ts","../../../src/app/services/file-upload.service.ts","../../../src/app/services/theme.service.ts","../../../src/app/help-center-widget/components/arrow-animation/arrow-animation.component.ts","../../../src/app/help-center-widget/components/arrow-animation/arrow-animation.component.html","../../../src/app/pipes/translate.pipe.ts","../../../src/app/shared/components/button/button.component.ts","../../../src/app/shared/components/button/button.component.html","../../../src/app/shared/components/icon/icon.component.ts","../../../src/app/shared/components/icon/icon.component.html","../../../src/app/help-center-widget/components/chat-action-buttons/chat-action-buttons.component.ts","../../../src/app/help-center-widget/components/chat-action-buttons/chat-action-buttons.component.html","../../../src/app/chat/components/chat-avatar/chat-avatar.component.ts","../../../src/app/chat/components/chat-avatar/chat-avatar.component.html","../../../src/app/chat/components/image-attachment/image-attachment.component.ts","../../../src/app/chat/components/image-attachment/image-attachment.component.html","../../../src/app/chat/components/image-preview-dialog/image-preview-dialog.component.ts","../../../src/app/chat/components/image-preview-dialog/image-preview-dialog.component.html","../../../src/app/shared/components/markdown-renderer/markdown-renderer.component.ts","../../../src/app/shared/components/markdown-renderer/markdown-renderer.component.html","../../../src/app/chat/components/chat-message/chat-message.component.ts","../../../src/app/chat/components/chat-message/chat-message.component.html","../../../src/app/chat/components/chat-separator/chat-separator.component.ts","../../../src/app/chat/components/chat-separator/chat-separator.component.html","../../../src/app/chat/components/chat-typing-indicator/chat-typing-indicator.component.ts","../../../src/app/chat/components/chat-typing-indicator/chat-typing-indicator.component.html","../../../src/app/chat/components/review-message/review-message.component.ts","../../../src/app/chat/components/review-message/review-message.component.html","../../../src/app/chat/chat.component.ts","../../../src/app/chat/chat.component.html","../../../src/app/shared/components/back-button/back-button.component.ts","../../../src/app/shared/components/back-button/back-button.component.html","../../../src/app/shared/components/close-button/close-button.component.ts","../../../src/app/shared/components/close-button/close-button.component.html","../../../src/app/shared/components/minimize-button/minimize-button.component.ts","../../../src/app/shared/components/minimize-button/minimize-button.component.html","../../../src/app/shared/components/chat-header/chat-header.component.ts","../../../src/app/shared/components/chat-header/chat-header.component.html","../../../src/app/shared/components/header/header.component.ts","../../../src/app/shared/components/header/header.component.html","../../../src/app/shared/components/card/card/card.component.ts","../../../src/app/shared/components/card/card/card.component.html","../../../src/app/shared/components/card/card-header/card-header.component.ts","../../../src/app/shared/components/card/card-header/card-header.component.html","../../../src/app/shared/components/card/card-title/card-title.component.ts","../../../src/app/shared/components/card/card-title/card-title.component.html","../../../src/app/shared/components/card/card-description/card-description.component.ts","../../../src/app/shared/components/card/card-description/card-description.component.html","../../../src/app/shared/components/card/card-content/card-content.component.ts","../../../src/app/shared/components/card/card-content/card-content.component.html","../../../src/app/shared/components/card/card-footer/card-footer.component.ts","../../../src/app/shared/components/card/card-footer/card-footer.component.html","../../../src/app/help-center-widget/components/footer/footer.component.ts","../../../src/app/help-center-widget/components/footer/footer.component.html","../../../src/app/help-center-widget/tokens.ts","../../../src/app/shared/components/loading/loading.component.ts","../../../src/app/shared/components/loading/loading.component.html","../../../src/app/shared/components/base-dialog/base-dialog.component.ts","../../../src/app/shared/components/base-dialog/base-dialog.component.html","../../../src/app/shared/components/confirmation-dialog/confirmation-dialog.component.ts","../../../src/app/shared/components/confirmation-dialog/confirmation-dialog.component.html","../../../src/app/shared/components/review-dialog/review-dialog.component.ts","../../../src/app/shared/components/review-dialog/review-dialog.component.html","../../../src/app/shared/components/index.ts","../../../src/app/chat/components/chat-input/chat-input.component.ts","../../../src/app/chat/components/chat-input/chat-input.component.html","../../../src/app/help-center-widget/components/chat-screen/chat-screen.component.ts","../../../src/app/help-center-widget/components/chat-screen/chat-screen.component.html","../../../src/app/help-center-widget/components/company-card/company-card.component.ts","../../../src/app/help-center-widget/components/company-card/company-card.component.html","../../../src/app/help-center-widget/components/help-button/help-button.component.ts","../../../src/app/help-center-widget/components/help-button/help-button.component.html","../../../src/app/shared/components/error-state/error-state.component.ts","../../../src/app/shared/components/error-state/error-state.component.html","../../../src/app/help-screen-list/components/helpscreen-option/helpscreen-option.component.ts","../../../src/app/help-screen-list/components/helpscreen-option/helpscreen-option.component.html","../../../src/app/help-screen-list/helpscreen-list.component.ts","../../../src/app/help-screen-list/helpscreen-list.component.html","../../../src/app/help-center-widget/components/home-screen/home-screen.component.ts","../../../src/app/help-center-widget/components/home-screen/home-screen.component.html","../../../src/app/help-center-widget/components/help-popup/help-popup.component.ts","../../../src/app/help-center-widget/components/help-popup/help-popup.component.html","../../../src/app/help-center-widget/help-center-widget.component.ts","../../../src/app/help-center-widget/help-center-widget.component.html","../../../src/app/services/help-center-config.service.ts","../../../src/app/services/token.service.ts","../../../public_api.ts","../../../aslaluroba-help-center.ts"],"sourcesContent":["import { Injectable } from '@angular/core';\n\n/**\n * Type definition for action handlers\n */\nexport type ActionHandler = (messageData: any) => void | Promise<void>;\n\n/**\n * Service for managing AI response action handlers in an extensible way.\n *\n * This service provides a registry pattern for handling different action types\n * from realtime AI responses, making it easy to add new actions without\n * modifying existing code.\n *\n * @publicApi\n */\n@Injectable({\n providedIn: 'root',\n})\nexport class ActionHandlerService {\n private actionHandlers = new Map<string, ActionHandler>();\n private defaultHandler?: ActionHandler;\n\n constructor() {\n // Register default handler for unknown actions\n this.setDefaultHandler((messageData) => {\n const actionType = messageData?.actionType || '';\n if (actionType && actionType !== '') {\n console.warn(\n `[ActionHandlerService] Unknown action type received: \"${actionType}\". ` +\n `Message data:`,\n messageData,\n );\n }\n });\n }\n\n /**\n * Registers a handler for a specific action type\n *\n * @param actionType The action type string (e.g., 'needs_agent', 'end_session')\n * @param handler The handler function to execute when this action is received\n */\n registerHandler(actionType: string, handler: ActionHandler): void {\n if (this.actionHandlers.has(actionType)) {\n console.warn(\n `[ActionHandlerService] Handler for action type \"${actionType}\" already exists. ` +\n `Overwriting with new handler.`,\n );\n }\n this.actionHandlers.set(actionType, handler);\n }\n\n /**\n * Unregisters a handler for a specific action type\n *\n * @param actionType The action type to unregister\n */\n unregisterHandler(actionType: string): void {\n this.actionHandlers.delete(actionType);\n }\n\n /**\n * Sets the default handler for unknown action types\n *\n * @param handler The default handler function\n */\n setDefaultHandler(handler: ActionHandler): void {\n this.defaultHandler = handler;\n }\n\n /**\n * Handles an action type by executing the registered handler or default handler\n *\n * @param actionType The action type to handle\n * @param messageData The message data containing the action\n * @returns Promise that resolves when handling is complete\n */\n async handleAction(\n actionType: string | undefined | null,\n messageData: any,\n ): Promise<void> {\n // Handle empty string or null/undefined - do nothing as per requirements\n if (!actionType || actionType === '') {\n return;\n }\n\n const handler = this.actionHandlers.get(actionType);\n\n if (handler) {\n try {\n await handler(messageData);\n } catch (error) {\n console.error(\n `[ActionHandlerService] Error executing handler for action type \"${actionType}\":`,\n error,\n );\n }\n } else if (this.defaultHandler) {\n // Use default handler for unknown actions\n try {\n await this.defaultHandler(messageData);\n } catch (error) {\n console.error(\n `[ActionHandlerService] Error executing default handler for action type \"${actionType}\":`,\n error,\n );\n }\n }\n }\n\n /**\n * Gets all registered action types\n *\n * @returns Array of registered action type strings\n */\n getRegisteredActions(): string[] {\n return Array.from(this.actionHandlers.keys());\n }\n\n /**\n * Clears all registered handlers (useful for testing or cleanup)\n */\n clearHandlers(): void {\n this.actionHandlers.clear();\n }\n}\n","import { Injectable } from '@angular/core';\nimport { ApiConfig } from '../../../public_api';\n\n/**\n * Service for handling API requests with automatic authentication token management.\n *\n * Features:\n * - Automatic token refresh on expiry\n * - Token validation and format checking\n * - Secure token storage using sessionStorage\n * - Endpoint validation to prevent SSRF attacks\n *\n * @publicApi\n */\n@Injectable({\n providedIn: 'root',\n})\nexport class ApiService {\n private getTokenFunction: (() => Promise<string>) | null = null;\n private baseUrl: string = 'https://babylai.net/api';\n\n /**\n * Initialize the API service with authentication configuration.\n *\n * This must be called before making any API requests. The service will use\n * the provided token function to fetch and refresh tokens automatically.\n *\n * @param config Configuration object containing token function and optional base URL\n * @throws Error if getToken function is not provided\n *\n * @example\n * ```typescript\n * apiService.initialize({\n * getToken: async () => {\n * const response = await fetch('/auth/token');\n * const data = await response.json();\n * return data.token;\n * },\n * baseUrl: 'https://api.example.com'\n * });\n * ```\n */\n initialize(config: ApiConfig) {\n if (!config.getToken) {\n throw new Error('getToken function is required for API initialization');\n }\n\n this.getTokenFunction = config.getToken;\n if (config.baseUrl) {\n // Enforce HTTPS for API calls when app is served over HTTPS\n // Allow localhost for development\n if (\n typeof window !== 'undefined' &&\n window.location.protocol === 'https:'\n ) {\n const baseUrl = config.baseUrl.toLowerCase();\n const isLocalhost =\n baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1');\n\n // If the app is running on HTTPS, enforce HTTPS for API calls (except localhost)\n if (!config.baseUrl.startsWith('https://') && !isLocalhost) {\n throw new Error(\n 'Security violation: API base URL must use HTTPS when app is served over HTTPS. ' +\n 'Non-HTTPS URLs are only allowed for localhost in development.',\n );\n }\n }\n this.baseUrl = config.baseUrl;\n }\n }\n\n /**\n * Validate token format\n * JWT tokens must have exactly 3 parts separated by dots (header.payload.signature)\n */\n private validateToken(token: string): boolean {\n if (!token || typeof token !== 'string') {\n return false;\n }\n\n // Basic validation - ensure token is not empty\n const trimmedToken = token.trim();\n if (trimmedToken.length === 0) {\n return false;\n }\n\n // JWT tokens must have exactly 3 parts separated by dots (header.payload.signature)\n const parts = trimmedToken.split('.');\n if (parts.length !== 3) {\n return false;\n }\n\n // Each part should be non-empty\n if (\n parts[0].length === 0 ||\n parts[1].length === 0 ||\n parts[2].length === 0\n ) {\n return false;\n }\n\n // Basic length check - JWT tokens are typically much longer than 10 characters\n // Header and payload are base64url encoded, signature is typically longer\n if (trimmedToken.length < 20) {\n return false;\n }\n\n return true;\n }\n\n /**\n * Retrieves a valid authentication token, fetching a new one if expired.\n *\n * Tokens are cached for 15 minutes. The service provides backward compatibility:\n * - Checks localStorage first for existing tokens (backward compatibility)\n * - Falls back to sessionStorage if no token found in localStorage\n * - New tokens are stored in sessionStorage (preferred) or localStorage (fallback)\n * - Automatically migrates tokens from localStorage to sessionStorage when found\n *\n * After expiry, a new token is automatically fetched using the configured getToken function.\n *\n * @param forceRefresh If true, forces a new token fetch even if current token is valid\n * @returns Promise resolving to a valid authentication token\n * @throws Error if API service is not initialized or token format is invalid\n */\n async getValidToken(forceRefresh = false): Promise<string> {\n if (!this.getTokenFunction) {\n throw new Error(\n 'API service not initialized. Call initialize({ getToken }) first.',\n );\n }\n\n // Determine storage mechanism - prefer sessionStorage for new tokens (more secure)\n // but check localStorage first for backward compatibility\n const sessionStorageAvailable =\n typeof window !== 'undefined' &&\n typeof window.sessionStorage !== 'undefined';\n const localStorageAvailable =\n typeof window !== 'undefined' &&\n typeof window.localStorage !== 'undefined';\n const sessionStore: Storage | null = sessionStorageAvailable\n ? window.sessionStorage\n : localStorageAvailable\n ? window.localStorage\n : null;\n const localStore: Storage | null = localStorageAvailable\n ? window.localStorage\n : null;\n\n const currentTime = Math.floor(Date.now() / 1000);\n let storedToken: string | null = null;\n let storedExpiry: string | null = null;\n\n // Backward compatibility: Check localStorage first for existing tokens\n if (localStore) {\n const localToken = localStore.getItem('chatbot-token');\n const localExpiry = localStore.getItem('chatbot-token-expiry');\n\n if (localToken && localExpiry && currentTime < Number(localExpiry)) {\n // Validate stored token format\n if (this.validateToken(localToken)) {\n storedToken = localToken;\n storedExpiry = localExpiry;\n\n // Migrate to sessionStorage for future use (if available)\n if (sessionStore && !forceRefresh) {\n sessionStore.setItem('chatbot-token', localToken);\n sessionStore.setItem('chatbot-token-expiry', localExpiry);\n }\n } else {\n // Invalid token in localStorage, clear it\n localStore.removeItem('chatbot-token');\n localStore.removeItem('chatbot-token-expiry');\n }\n }\n }\n\n // If no valid token found in localStorage, check sessionStorage\n if (!storedToken && sessionStore) {\n const sessionToken = sessionStore.getItem('chatbot-token');\n const sessionExpiry = sessionStore.getItem('chatbot-token-expiry');\n\n if (\n sessionToken &&\n sessionExpiry &&\n currentTime < Number(sessionExpiry)\n ) {\n // Validate stored token format\n if (this.validateToken(sessionToken)) {\n storedToken = sessionToken;\n storedExpiry = sessionExpiry;\n } else {\n // Invalid token in sessionStorage, clear it\n sessionStore.removeItem('chatbot-token');\n sessionStore.removeItem('chatbot-token-expiry');\n }\n }\n }\n\n // Fetch new token if needed\n if (\n !storedToken ||\n !storedExpiry ||\n currentTime >= Number(storedExpiry) ||\n forceRefresh\n ) {\n const tokenResponse = await this.getTokenFunction();\n\n if (!tokenResponse) {\n throw new Error('Invalid token response from getToken function');\n }\n\n // Validate token format before storing\n if (!this.validateToken(tokenResponse)) {\n throw new Error('Invalid token format received from getToken function');\n }\n\n storedToken = tokenResponse;\n storedExpiry = String(currentTime + 900); // 15 minutes expiry\n\n // Store new tokens in sessionStorage (preferred) or localStorage (fallback)\n const targetStorage = sessionStore || localStore;\n if (targetStorage) {\n targetStorage.setItem('chatbot-token', storedToken);\n targetStorage.setItem('chatbot-token-expiry', storedExpiry);\n\n // Clean up old localStorage token if it exists (migration)\n if (localStore && targetStorage === sessionStore) {\n localStore.removeItem('chatbot-token');\n localStore.removeItem('chatbot-token-expiry');\n }\n }\n }\n\n return storedToken!;\n }\n\n private async fetchWithAuth(\n url: string,\n options: RequestInit,\n retry = true,\n ): Promise<Response> {\n if (!options.headers) {\n options.headers = {};\n }\n\n const headers = options.headers as Record<string, string>;\n headers['Authorization'] = `Bearer ${await this.getValidToken()}`;\n\n let response = await fetch(url, options);\n\n if ((response.status === 401 || response.status === 403) && retry) {\n console.warn('Token expired. Fetching new token...');\n\n const newToken = await this.getValidToken(true);\n headers['Authorization'] = `Bearer ${newToken}`;\n\n response = await fetch(url, options);\n }\n\n return response;\n }\n\n /**\n * Validate endpoint to prevent SSRF vulnerabilities\n *\n * Protects against:\n * - Absolute URLs with any protocol (case-insensitive)\n * - Protocol-relative URLs\n * - Dangerous protocols (ftp, file, data, javascript, etc.)\n * - URL-encoded path traversal\n * - Double-encoded attacks\n */\n private validateEndpoint(endpoint: string): void {\n // Ensure endpoint is not empty\n if (!endpoint || endpoint.trim().length === 0) {\n throw new Error('Invalid endpoint: endpoint cannot be empty');\n }\n\n // Normalize endpoint for case-insensitive checks\n const normalized = endpoint.trim();\n const lowercased = normalized.toLowerCase();\n\n // Prevent protocol-relative URLs (//example.com)\n if (normalized.startsWith('//')) {\n throw new Error('Invalid endpoint: protocol-relative URLs not allowed');\n }\n\n // Check for dangerous protocols (case-insensitive)\n // List of dangerous protocols that could be used for SSRF\n const dangerousProtocols = [\n 'http://',\n 'https://',\n 'ftp://',\n 'file://',\n 'data:',\n 'javascript:',\n 'mailto:',\n 'tel:',\n 'ws://',\n 'wss://',\n 'gopher://',\n 'ldap://',\n 'ldaps://',\n ];\n\n for (const protocol of dangerousProtocols) {\n if (lowercased.startsWith(protocol)) {\n throw new Error(\n `Invalid endpoint: absolute URLs with protocol '${protocol}' not allowed`,\n );\n }\n }\n\n // Decode URL-encoded strings to catch encoded attacks\n let decodedEndpoint: string;\n try {\n // Decode once to catch single-encoded attacks\n decodedEndpoint = decodeURIComponent(normalized);\n // Decode twice to catch double-encoded attacks\n decodedEndpoint = decodeURIComponent(decodedEndpoint);\n } catch {\n // If decoding fails, it might be malicious - reject it\n throw new Error('Invalid endpoint: malformed URL encoding detected');\n }\n\n // Check decoded endpoint for path traversal patterns\n const decodedLowercased = decodedEndpoint.toLowerCase();\n\n // Check for path traversal in various forms\n const pathTraversalPatterns = [\n '..',\n '%2e%2e',\n '%2e%2e/',\n '../',\n '..\\\\',\n '%2f',\n '%5c',\n ];\n\n for (const pattern of pathTraversalPatterns) {\n if (\n decodedLowercased.includes(pattern) ||\n normalized.toLowerCase().includes(pattern)\n ) {\n throw new Error('Invalid endpoint: path traversal detected');\n }\n }\n\n // Check for dangerous protocols in decoded string\n for (const protocol of dangerousProtocols) {\n if (decodedLowercased.startsWith(protocol)) {\n throw new Error(\n `Invalid endpoint: dangerous protocol detected in URL-encoded string`,\n );\n }\n }\n\n // Prevent control characters and other dangerous characters\n // Check for control characters (0x00-0x1F) and DEL (0x7F) using character codes\n for (let i = 0; i < normalized.length; i++) {\n const charCode = normalized.charCodeAt(i);\n if ((charCode >= 0x00 && charCode <= 0x1f) || charCode === 0x7f) {\n throw new Error('Invalid endpoint: control characters not allowed');\n }\n }\n }\n\n /**\n * Makes an authenticated API request with automatic token management.\n *\n * This method automatically:\n * - Validates the endpoint to prevent SSRF attacks\n * - Adds authentication headers with a valid token\n * - Retries with a fresh token if authentication fails\n * - Handles errors and returns appropriate error messages\n *\n * @param endpoint API endpoint path (relative to baseUrl)\n * @param method HTTP method (default: 'GET')\n * @param body Request body object (will be JSON stringified)\n * @param customHeaders Additional headers to include in the request\n * @returns Promise resolving to the Response object\n * @throws Error if endpoint validation fails, request fails, or authentication fails\n *\n * @example\n * ```typescript\n * // GET request\n * const response = await apiService.apiRequest('client/help-screens/123');\n * const data = await response.json();\n *\n * // POST request\n * const response = await apiService.apiRequest(\n * 'client/chat-sessions',\n * 'POST',\n * { helpScreenId: '123' }\n * );\n * ```\n */\n async apiRequest<T = unknown>(\n endpoint: string,\n method = 'GET',\n body: T | null = null,\n customHeaders: Record<string, string> = {},\n ): Promise<Response> {\n // Validate endpoint before making request\n this.validateEndpoint(endpoint);\n\n const url = `${this.baseUrl}/${endpoint}`;\n\n const options: RequestInit = {\n method,\n headers: {\n 'Content-Type': 'application/json',\n ...customHeaders,\n },\n body: body ? JSON.stringify(body) : null,\n };\n\n const response = await this.fetchWithAuth(url, options);\n\n if (!response.ok) {\n const errorData = await response.json();\n throw new Error(errorData.message || 'API request failed');\n }\n\n return response;\n }\n}\n","// Note: Using namespace import for tree-shaking support\n// Modern bundlers (webpack, esbuild) should tree-shake unused exports\n// Only Realtime class and related types are used, so tree-shaking should work\nimport * as Ably from 'ably';\n\ntype MessageReceivedCallback = (\n message: string | { content: string; attachments?: string[] },\n senderType: number,\n needsAgent: boolean,\n) => void;\n\ntype ActionHandlerCallback = (\n actionType: string,\n messageData: any,\n) => void | Promise<void>;\n\nexport class ClientAblyService {\n private static client: Ably.Realtime | null = null;\n private static channel: Ably.RealtimeChannel | null = null;\n private static isConnected: boolean = false;\n private static sessionId: string | null = null;\n private static messageUnsubscribe: (() => void) | null = null;\n private static connectionTimeout?: ReturnType<typeof setTimeout>;\n\n /**\n * Normalize backend attachments to string[] of image sources (URLs or file IDs).\n * Handles: array of strings/objects (downloadUrl, url, id), JSON strings, or empty strings.\n * URLs are used directly; IDs are presigned by ImageAttachmentComponent.\n */\n private static normalizeAttachmentIds(raw: unknown): string[] {\n if (raw == null) return [];\n // Handle empty strings (backend sends \"\" when no attachments)\n if (typeof raw === 'string') {\n if (raw === '') return [];\n // Try parsing as JSON in case backend sends JSON string\n try {\n const parsed = JSON.parse(raw);\n return this.normalizeAttachmentIds(parsed);\n } catch {\n // Not valid JSON; treat as single source (URL or ID)\n return [raw];\n }\n }\n if (Array.isArray(raw)) {\n if (raw.length === 0) return [];\n const sources: string[] = [];\n for (const attachment of raw) {\n if (typeof attachment === 'string') {\n sources.push(attachment);\n } else if (attachment && typeof attachment === 'object') {\n const o = attachment as {\n downloadUrl?: string;\n url?: string;\n id?: string;\n };\n if (o.downloadUrl && typeof o.downloadUrl === 'string') {\n sources.push(o.downloadUrl);\n } else if (o.url && typeof o.url === 'string') {\n sources.push(o.url);\n } else if (o.id && typeof o.id === 'string') {\n sources.push(o.id);\n }\n }\n }\n return sources;\n }\n if (typeof raw === 'object' && raw !== null) {\n const o = raw as Record<string, unknown>;\n const arr = (o['ids'] ?? o['fileIds'] ?? o['attachmentIds']) as unknown;\n return this.normalizeAttachmentIds(arr);\n }\n return [];\n }\n\n static async startConnection(\n sessionId: string,\n ablyToken: string,\n onMessageReceived: MessageReceivedCallback,\n tenantId: string,\n getAblyToken?: (\n tokenParams: Ably.TokenParams,\n ) => Promise<Ably.TokenDetails | string>,\n onActionReceived?: ActionHandlerCallback,\n ) {\n // Prevent multiple connections\n if (this.isConnected && this.sessionId === sessionId) {\n return;\n }\n\n // Close existing connection if connecting to a different session\n if (this.isConnected && this.sessionId !== sessionId) {\n await this.stopConnection();\n }\n\n try {\n // Initialize Ably client with token refresh support\n const clientOptions: Ably.ClientOptions = {\n autoConnect: true,\n token: ablyToken, // Use initial token for immediate connection\n };\n\n if (getAblyToken) {\n // Use authCallback for token renewal when callback is provided\n // When both token and authCallback are provided, token is used for initial connection\n // and authCallback is used for token refresh\n clientOptions.authCallback = getAblyToken;\n }\n\n this.client = new Ably.Realtime(clientOptions);\n\n // Wait for connection to be established\n await new Promise<void>((resolve, reject) => {\n if (!this.client) {\n reject(new Error('Failed to initialize Ably client'));\n return;\n }\n\n this.client.connection.once('connected', () => {\n // Clear connection timeout if connection succeeds\n if (this.connectionTimeout) {\n clearTimeout(this.connectionTimeout);\n this.connectionTimeout = undefined;\n }\n this.isConnected = true;\n this.sessionId = sessionId;\n resolve();\n });\n\n this.client.connection.once('failed', (stateChange) => {\n // Clear connection timeout on failure\n if (this.connectionTimeout) {\n clearTimeout(this.connectionTimeout);\n this.connectionTimeout = undefined;\n }\n console.error('Ably connection failed:', stateChange);\n reject(\n new Error(\n `Ably connection failed: ${\n stateChange.reason?.message || 'Unknown error'\n }`,\n ),\n );\n });\n\n this.client.connection.once('disconnected', (stateChange) => {\n // Clear connection timeout on disconnect\n if (this.connectionTimeout) {\n clearTimeout(this.connectionTimeout);\n this.connectionTimeout = undefined;\n }\n console.error('Ably connection disconnected:', stateChange);\n reject(\n new Error(\n `Ably connection disconnected: ${\n stateChange.reason?.message || 'Unknown error'\n }`,\n ),\n );\n });\n\n // Set a timeout for connection\n this.connectionTimeout = setTimeout(() => {\n if (!this.isConnected) {\n // Clean up resources on timeout\n const clientToCleanup = this.client;\n this.connectionTimeout = undefined;\n\n // Close and clear the client connection\n if (clientToCleanup) {\n try {\n clientToCleanup.close();\n } catch (cleanupError) {\n console.error('Error closing client on timeout:', cleanupError);\n }\n this.client = null;\n }\n\n // Reset connection state\n this.isConnected = false;\n this.sessionId = null;\n this.channel = null;\n this.messageUnsubscribe = null;\n\n reject(new Error('Ably connection timeout'));\n }\n }, 10000);\n });\n\n // Subscribe to the session room\n await this.joinChannel(\n sessionId,\n onMessageReceived,\n tenantId,\n onActionReceived,\n );\n } catch (error) {\n console.error('Error during Ably connection setup:', error);\n\n // Clean up resources on error (including timeout)\n if (this.client) {\n try {\n this.client.close();\n } catch (cleanupError) {\n console.error('Error closing client on error:', cleanupError);\n }\n this.client = null;\n }\n\n // Clear connection timeout if still pending\n if (this.connectionTimeout) {\n clearTimeout(this.connectionTimeout);\n this.connectionTimeout = undefined;\n }\n\n // Reset all connection state\n this.isConnected = false;\n this.sessionId = null;\n this.channel = null;\n this.messageUnsubscribe = null;\n\n throw error;\n }\n }\n\n private static async joinChannel(\n sessionId: string,\n onMessageReceived: MessageReceivedCallback,\n tenantId: string,\n onActionReceived?: ActionHandlerCallback,\n ) {\n if (!this.client) {\n throw new Error('Chat client not initialized');\n }\n\n const roomName = `session:${tenantId}:${sessionId}`;\n\n // Set up raw channel subscription for server messages\n if (this.client) {\n this.channel = this.client.channels.get(roomName);\n\n // Subscribe to assistant/system responses\n this.channel.subscribe('ReceiveMessage', (message) => {\n try {\n const messageData =\n typeof message.data === 'string'\n ? { content: message.data }\n : message.data || {};\n const messageContent =\n typeof messageData === 'string'\n ? messageData\n : messageData?.content || messageData?.message || '';\n const senderType = messageData?.senderType || 3; // Assistant\n // Extract attachments from backend (can be array of strings/objects or empty string)\n // Backend sends attachments as array when present, empty string when none\n const attachmentsRaw = messageData?.attachments;\n const attachmentIdsRaw = messageData?.attachmentIds;\n // Prioritize attachments array if it exists and has items, otherwise check attachmentIds field\n // If attachments is an array with items, use it; else if attachmentIds is an array, use it; else use attachmentsRaw (might be empty string)\n const rawAttachments =\n Array.isArray(attachmentsRaw) && attachmentsRaw.length > 0\n ? attachmentsRaw\n : Array.isArray(attachmentIdsRaw) && attachmentIdsRaw.length > 0\n ? attachmentIdsRaw\n : (attachmentsRaw ?? attachmentIdsRaw);\n const attachmentIds = this.normalizeAttachmentIds(rawAttachments);\n const actionType = messageData?.actionType;\n\n // Handle action type using extensible action handler\n // This supports all action types (known and unknown) in a safe, extensible way\n if (\n onActionReceived &&\n actionType !== undefined &&\n actionType !== null\n ) {\n // Execute action handler (may be async, but we don't await to avoid blocking message processing)\n Promise.resolve(onActionReceived(actionType, messageData)).catch(\n (error) => {\n console.error('Error handling action type:', actionType, error);\n },\n );\n }\n\n // Extract needsAgent flag (for backward compatibility)\n // This is separate from action handling to maintain existing behavior\n const needsAgent =\n messageData?.needsAgent ||\n messageData?.actionType == 'needs_agent' ||\n false;\n\n // Pass message as object if it has attachments, otherwise as string for backward compatibility\n const messageToPass =\n attachmentIds.length > 0\n ? { content: messageContent, attachments: attachmentIds }\n : messageContent;\n\n onMessageReceived(messageToPass, senderType, needsAgent);\n } catch (error) {\n console.error('Error processing ReceiveMessage:', error);\n }\n });\n\n await this.channel.attach();\n }\n }\n\n static async stopConnection() {\n try {\n // Clear connection timeout if still pending\n if (this.connectionTimeout) {\n clearTimeout(this.connectionTimeout);\n this.connectionTimeout = undefined;\n }\n\n // Unsubscribe from room messages\n if (this.messageUnsubscribe) {\n this.messageUnsubscribe();\n this.messageUnsubscribe = null;\n }\n\n // Unsubscribe and detach from raw channel\n if (this.channel) {\n this.channel.unsubscribe();\n await this.channel.detach();\n this.channel = null;\n }\n\n // Close Ably connection\n if (this.client) {\n this.client.close();\n this.client = null;\n }\n\n this.isConnected = false;\n this.sessionId = null;\n } catch (error) {\n console.error('Error stopping Ably connection:', error);\n // Reset state even if there's an error\n this.isConnected = false;\n this.sessionId = null;\n this.client = null;\n this.channel = null;\n this.messageUnsubscribe = null;\n this.connectionTimeout = undefined;\n }\n }\n\n static isConnectionActive(): boolean {\n return this.isConnected && this.client?.connection.state === 'connected';\n }\n\n static getConnectionState(): string {\n return this.client?.connection.state || 'disconnected';\n }\n\n // Method to manually send a message (if needed for debugging or direct messaging)\n static async sendMessage(messageContent: string, senderType: number = 1) {\n if (!this.channel || !this.isConnected) {\n throw new Error('Connection not active');\n }\n\n try {\n const messageData = {\n text: messageContent,\n metadata: {\n senderType,\n sentAt: new Date().toISOString(),\n },\n };\n\n await this.channel.publish('message', messageData);\n } catch (error) {\n console.error('Error sending message:', error);\n throw error;\n }\n }\n}\n","import { Injectable, inject } from '@angular/core';\nimport * as Ably from 'ably';\nimport { Option } from '../types';\nimport { ClientAblyService } from './ably.service';\nimport { ActionHandlerService } from './action-handler.service';\nimport { ApiService } from './api.service';\n\n/**\n * Service for managing chat session lifecycle and operations.\n *\n * Handles:\n * - Chat session creation and management\n * - Ably connection management\n * - Message sending operations\n *\n * @publicApi\n */\n@Injectable({\n providedIn: 'root',\n})\nexport class ChatSessionService {\n private apiService = inject(ApiService);\n private actionHandlerService = inject(ActionHandlerService);\n\n /**\n * Creates a new chat session with the specified option.\n *\n * @param option The help screen option to create a chat session for\n * @param helpScreenId The help screen ID\n * @param currentLang Current language code for API headers\n * @returns Promise resolving to session data including sessionId and ablyToken\n * @throws Error if session creation fails\n */\n async createSession(\n option: Option,\n helpScreenId: string,\n currentLang: string,\n ): Promise<{ chatSession: { id: string }; ablyToken: string }> {\n const chatSessionCreateDto = {\n optionId: option.id,\n helpScreenId: helpScreenId,\n };\n\n const response = await this.apiService.apiRequest(\n 'Client/ClientChatSession/create-session',\n 'POST',\n chatSessionCreateDto,\n {\n 'Accept-Language': currentLang,\n },\n );\n\n return await response.json();\n }\n\n /**\n * Establishes Ably real-time connection for a chat session.\n *\n * @param sessionId The chat session ID\n * @param ablyToken The Ably authentication token\n * @param onMessageReceived Callback function for received messages\n * @param tenantId The tenant ID for the connection\n * @param option Optional option for token refresh (needed for token renewal)\n * @param helpScreenId Optional help screen ID for token refresh\n * @param currentLang Optional current language for token refresh\n * @param onActionReceived Optional callback function for handling action types\n */\n async establishAblyConnection(\n sessionId: string,\n ablyToken: string,\n onMessageReceived: (\n message: string | { content: string; attachments?: string[] },\n senderType: number,\n needsAgent: boolean,\n ) => void,\n tenantId: string,\n option?: Option,\n helpScreenId?: string,\n currentLang?: string,\n onActionReceived?: (\n actionType: string,\n messageData: any,\n ) => void | Promise<void>,\n ): Promise<void> {\n // Create token refresh callback if context is provided\n let getAblyToken:\n | ((tokenParams: Ably.TokenParams) => Promise<Ably.TokenDetails | string>)\n | undefined;\n\n if (option && helpScreenId && currentLang) {\n getAblyToken = async () => {\n try {\n // This callback is only called when the token needs to be refreshed\n // The initial token is provided via the 'token' option in Ably client options\n // Create a new session to get a fresh token\n // Note: This will create a new session, but we keep using the original sessionId\n // In the future, if there's a dedicated refresh endpoint, use that instead\n const sessionData = await this.createSession(\n option,\n helpScreenId,\n currentLang,\n );\n return sessionData.ablyToken;\n } catch (error) {\n console.error('Error refreshing Ably token:', error);\n throw error;\n }\n };\n }\n\n // Create action handler callback that uses the ActionHandlerService\n const actionHandler =\n onActionReceived ||\n ((actionType: string, messageData: any) => {\n this.actionHandlerService.handleAction(actionType, messageData);\n });\n\n await ClientAblyService.startConnection(\n sessionId,\n ablyToken,\n onMessageReceived,\n tenantId,\n getAblyToken,\n actionHandler,\n );\n }\n\n /**\n * Sends a message in an active chat session.\n *\n * @param sessionId The chat session ID\n * @param messageContent The message content to send\n * @param currentLang Current language code for API headers\n * @throws Error if message sending fails\n */\n async sendMessage(\n sessionId: string,\n messageContent: string,\n currentLang: string,\n attachmentIds?: string[],\n ): Promise<void> {\n const messageDto: { messageContent: string; attachmentIds?: string[] } = {\n messageContent,\n };\n if (attachmentIds && attachmentIds.length > 0) {\n messageDto.attachmentIds = attachmentIds;\n }\n\n await this.apiService.apiRequest(\n `Client/ClientChatSession/${sessionId}/send-message`,\n 'POST',\n messageDto,\n {\n 'Accept-Language': currentLang,\n },\n );\n }\n\n /**\n * Closes a chat session.\n *\n * @param sessionId The chat session ID to close\n * @param currentLang Current language code for API headers\n * @returns Promise resolving to the close response\n * @throws Error if session closing fails\n */\n async closeSession(sessionId: string, currentLang: string): Promise<unknown> {\n const response = await this.apiService.apiRequest(\n `Client/ClientChatSession/${sessionId}/close`,\n 'POST',\n null,\n {\n 'Accept-Language': currentLang,\n },\n );\n\n return await response.json();\n }\n\n /**\n * Submits a review for a chat session.\n *\n * @param sessionId The chat session ID\n * @param rating Rating value (1-5)\n * @param comment Review comment (10-500 characters)\n * @param currentLang Current language code for API headers\n * @throws Error if review submission fails\n */\n async submitReview(\n sessionId: string,\n rating: number,\n comment: string,\n currentLang: string,\n ): Promise<void> {\n const reviewPayload = {\n rating,\n comment,\n };\n\n await this.apiService.apiRequest(\n `Client/ClientChatSession/${sessionId}/review`,\n 'POST',\n reviewPayload,\n {\n 'Accept-Language': currentLang,\n },\n );\n }\n\n /**\n * Stops the Ably connection.\n */\n async stopConnection(): Promise<void> {\n await ClientAblyService.stopConnection();\n }\n\n /**\n * Checks if Ably connection is active.\n *\n * @returns True if connection is active, false otherwise\n */\n isConnectionActive(): boolean {\n return ClientAblyService.isConnectionActive();\n }\n}\n","// src/app/services/translation.service.ts\nimport { Injectable } from '@angular/core';\nimport { BehaviorSubject, Observable } from 'rxjs';\nimport { Language } from '../types';\n\nexport type TranslationKey =\n | 'ChatIntroMessage'\n | 'BabylaiTitle'\n | 'BabylaiDescription'\n | 'ChatNow'\n | 'TryBableAI'\n | 'ContactUs'\n | 'HelpCenter'\n | 'BabylAI'\n | 'ChatPlaceholder'\n | 'PoweredByBabylAI'\n | 'EndChat'\n | 'LeavingDialogTitle'\n | 'LeavingDialogBody'\n | 'StartNewChatDialogTitle'\n | 'StartNewChatDialogBody'\n | 'ReviewDialogTitle'\n | 'ReviewDialogDescription'\n | 'ReviewDialogRatingLabel'\n | 'ReviewDialogCommentLabel'\n | 'ReviewDialogCommentPlaceholder'\n | 'ReviewDialogSubmitButton'\n | 'ReviewDialogSkipButton'\n | 'Confirm'\n | 'Cancel'\n | 'Close'\n | 'Back'\n | 'title'\n | 'ErrorFetchingHelpScreen'\n | 'ErrorSendingMessage'\n | 'ErrorStartingChat'\n | 'ErrorEndingChat'\n | 'ErrorSubmittingReview'\n | 'ErrorClosingChat'\n | 'ErrorCreatingSession'\n | 'ErrorNetworkFailure'\n | 'ErrorUnknown'\n | 'ErrorMessageTooLong'\n | 'ErrorMessageEmpty'\n | 'CloseChat'\n | 'Continue';\n/**\n * Service for managing translations and language switching.\n *\n * Supports multiple languages (currently English and Arabic) with RTL support.\n * Provides reactive language changes through Observable pattern.\n *\n * @publicApi\n */\n@Injectable({\n providedIn: 'root',\n})\nexport class TranslationService {\n private translations = {\n en: {\n ChatIntroMessage: 'Chat with BabylAI 🚀',\n BabylaiTitle: 'BabylAI',\n BabylaiDescription: \"Hey there! 👋 I'm BabylAI, here to assist you.\",\n ChatNow: 'Chat Now',\n TryBableAI: 'Try BabylAI for Free 🎉',\n ContactUs: \"Contact us, Let's Talk! 💬\",\n HelpCenter: 'Help Center',\n BabylAI: 'BabylAI',\n ChatPlaceholder: 'Type your message...',\n PoweredByBabylAI: 'Powered by',\n EndChat: 'End Chat',\n LeavingDialogTitle: 'Leaving so soon? 👋',\n LeavingDialogBody:\n \"Don't worry, you can come back anytime. We're always here if you need help or have questions.\",\n StartNewChatDialogTitle: 'End and Start New Chat',\n StartNewChatDialogBody:\n 'Are you sure you want to end the current conversation and start a new one?',\n ReviewDialogTitle: \"We'd Love Your Feedback!\",\n ReviewDialogDescription:\n 'Your opinion helps us improve and deliver better support. Please rate your experience with our service and let us know how we did.',\n ReviewDialogRatingLabel: 'Rating:',\n ReviewDialogCommentLabel:\n 'Awesome! We’re glad you enjoyed chatting with us. Could you rate your overall experience to help us keep it that way?',\n ReviewDialogCommentPlaceholder: 'Write your comment here...',\n ReviewDialogSubmitButton: 'Submit Review',\n ReviewDialogSkipButton: 'Skip',\n Confirm: 'Confirm',\n Cancel: 'Cancel',\n Close: 'Close',\n Back: 'Back',\n title: 'Help Center',\n ErrorFetchingHelpScreen: 'Failed to load help screen. Please try again.',\n ErrorSendingMessage: 'Failed to send the message. Please try again.',\n ErrorStartingChat: 'Failed to start chat. Please try again.',\n ErrorEndingChat: 'Failed to end chat. Please try again.',\n ErrorSubmittingReview: 'Failed to submit review. Please try again.',\n ErrorClosingChat: 'Failed to close chat session.',\n ErrorCreatingSession: 'Failed to create chat session. Please try again.',\n ErrorNetworkFailure:\n 'Network error. Please check your connection and try again.',\n ErrorUnknown: 'An unexpected error occurred. Please try again.',\n ErrorMessageTooLong:\n 'Message is too long. Maximum 5000 characters allowed.',\n ErrorMessageEmpty: 'Message cannot be empty.',\n CloseChat: 'Close Chat',\n Continue: 'Continue',\n },\n ar: {\n ChatIntroMessage: 'دردش مع BabylAI 🚀',\n BabylaiTitle: 'BabylAI',\n BabylaiDescription: 'مرحبا! 👋 أنا BabylAI، هنا لتساعدك.',\n ChatNow: 'دعنا نتحدث',\n TryBableAI: 'جرب BabylAI مجانا 🎉',\n ContactUs: 'تواصل معنا, دعنا نتحدث! 💬',\n HelpCenter: 'مركز المساعدة',\n BabylAI: 'BabylAI',\n ChatPlaceholder: 'اكتب رسالتك...',\n PoweredByBabylAI: 'مدعوم من',\n EndChat: 'إنهاء الدردشة',\n LeavingDialogTitle: 'هل تغادر بالفعل؟ 👋',\n LeavingDialogBody:\n 'لا تقلق، يمكنك العودة في أي وقت. نحن دائماً هنا إذا كنت بحاجة إلى مساعدة أو لديك أسئلة.',\n StartNewChatDialogTitle: 'إنهاء وبدء دردشة جديدة',\n StartNewChatDialogBody:\n 'هل أنت متأكد من أنك تريد إنهاء المحادثة الحالية وبدء محادثة جديدة؟',\n ReviewDialogTitle: 'كيف كانت تجربتك معنا؟',\n ReviewDialogDescription:\n 'نقدر ملاحظاتك! يرجى قضاء لحظة لتقييم تجربتك ومشاركة أفكارك في قسم التعليقات أدناه. تقييمك يساعدنا في تحسين خدماتنا ويساعد المستخدمين الآخرين في اتخاذ قرارات مدروسة. شكراً لك!',\n ReviewDialogRatingLabel: 'التقييم:',\n ReviewDialogCommentLabel:\n 'شكراً لك! يرجى قضاء لحظة لتقييم تجربتك ومشاركة أفكارك في قسم التعليقات أدناه. تقييمك يساعدنا في تحسين خدماتنا ويساعد المستخدمين الآخرين في اتخاذ قرارات مدروسة.',\n ReviewDialogCommentPlaceholder: 'اكتب تعليقك هنا...',\n ReviewDialogSubmitButto