UNPKG

@longears-mobile/rcs-sdk

Version:

Provider-agnostic SDK for RCS (Rich Communication Services) messaging

792 lines (781 loc) 28.3 kB
import pino from 'pino'; import axios from 'axios'; import crypto from 'crypto'; var RCSErrorCode; (function (RCSErrorCode) { // General errors RCSErrorCode["UNKNOWN"] = "UNKNOWN"; RCSErrorCode["INITIALIZATION_FAILED"] = "INITIALIZATION_FAILED"; RCSErrorCode["NOT_INITIALIZED"] = "NOT_INITIALIZED"; // Provider errors RCSErrorCode["PROVIDER_NOT_FOUND"] = "PROVIDER_NOT_FOUND"; RCSErrorCode["PROVIDER_ERROR"] = "PROVIDER_ERROR"; // Authentication errors RCSErrorCode["AUTH_FAILED"] = "AUTH_FAILED"; RCSErrorCode["AUTH_EXPIRED"] = "AUTH_EXPIRED"; RCSErrorCode["AUTH_INVALID"] = "AUTH_INVALID"; // Message errors RCSErrorCode["MESSAGE_SEND_FAILED"] = "MESSAGE_SEND_FAILED"; RCSErrorCode["MESSAGE_TOO_LONG"] = "MESSAGE_TOO_LONG"; RCSErrorCode["INVALID_CONTENT"] = "INVALID_CONTENT"; // Phone number errors RCSErrorCode["INVALID_PHONE_NUMBER"] = "INVALID_PHONE_NUMBER"; RCSErrorCode["RCS_NOT_SUPPORTED"] = "RCS_NOT_SUPPORTED"; // Capability errors RCSErrorCode["CAPABILITY_CHECK_FAILED"] = "CAPABILITY_CHECK_FAILED"; RCSErrorCode["FEATURE_NOT_SUPPORTED"] = "FEATURE_NOT_SUPPORTED"; // Network errors RCSErrorCode["NETWORK_ERROR"] = "NETWORK_ERROR"; RCSErrorCode["TIMEOUT"] = "TIMEOUT"; RCSErrorCode["RATE_LIMIT_EXCEEDED"] = "RATE_LIMIT_EXCEEDED"; // Validation errors RCSErrorCode["VALIDATION_FAILED"] = "VALIDATION_FAILED"; RCSErrorCode["INVALID_CONFIGURATION"] = "INVALID_CONFIGURATION"; })(RCSErrorCode || (RCSErrorCode = {})); class RCSError extends Error { code; provider; details; timestamp; constructor(message, code = RCSErrorCode.UNKNOWN, provider, details) { super(message); this.name = 'RCSError'; this.code = code; this.provider = provider; this.details = details; this.timestamp = new Date(); // Ensure proper prototype chain Object.setPrototypeOf(this, RCSError.prototype); } toJSON() { return { name: this.name, message: this.message, code: this.code, provider: this.provider, details: this.details, timestamp: this.timestamp, stack: this.stack }; } static fromProviderError(provider, error) { // Map provider-specific errors to RCS error codes let code = RCSErrorCode.PROVIDER_ERROR; let message = error.message || 'Provider error occurred'; // Generic HTTP status code mapping if (error.code) { switch (error.code) { case 404: code = RCSErrorCode.RCS_NOT_SUPPORTED; message = 'Phone number does not support RCS'; break; case 401: case 403: code = RCSErrorCode.AUTH_FAILED; message = 'Authentication failed'; break; case 429: code = RCSErrorCode.RATE_LIMIT_EXCEEDED; message = 'Rate limit exceeded'; break; case 400: if (error.message?.includes('phone')) { code = RCSErrorCode.INVALID_PHONE_NUMBER; } else if (error.message?.includes('content')) { code = RCSErrorCode.INVALID_CONTENT; } break; } } return new RCSError(message, code, provider, error); } static isRCSError(error) { return error instanceof RCSError; } } const level = process.env.LOG_LEVEL || 'info'; const isDevelopment = process.env.NODE_ENV === 'development'; const logger = pino({ level, transport: isDevelopment ? { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss', ignore: 'pid,hostname', }, } : undefined, formatters: { level: (label) => { return { level: label }; }, }, base: { sdk: '@longears-mobile/rcs-sdk', }, }); class BaseProvider { auth; config; http; constructor(auth, config) { this.auth = auth; this.config = config; // Create axios instance with default config this.http = axios.create({ timeout: config.timeout || 30000, headers: { 'Content-Type': 'application/json', 'User-Agent': `@longears-mobile/rcs-sdk/${process.env.npm_package_version || '0.1.0'}` } }); // Add request interceptor for authentication this.http.interceptors.request.use(async (config) => { try { // Get auth token const token = await this.auth.authenticate(); // Add auth header based on token type if (token.type && token.type === 'Bearer') { config.headers.Authorization = `Bearer ${token.token}`; } else if (token.type && token.type === 'Basic') { config.headers.Authorization = `Basic ${token.token}`; } else { config.headers.Authorization = token.token; } return config; } catch (error) { logger.error('Authentication failed:', error); throw new RCSError('Failed to authenticate request', RCSErrorCode.AUTH_FAILED, this.name, error); } }); // Add response interceptor for error handling this.http.interceptors.response.use((response) => response, (error) => { logger.error(`HTTP request failed for ${this.name} provider:`, error); if (error.response) { // The request was made and the server responded with a status code // that falls out of the range of 2xx const status = error.response.status; const data = error.response.data; switch (status) { case 401: case 403: throw new RCSError('Authentication failed', RCSErrorCode.AUTH_FAILED, this.name, { status, data }); case 404: throw new RCSError('Resource not found', RCSErrorCode.PROVIDER_ERROR, this.name, { status, data }); case 429: throw new RCSError('Rate limit exceeded', RCSErrorCode.RATE_LIMIT_EXCEEDED, this.name, { status, data }); default: throw RCSError.fromProviderError(this.name, { code: status, message: data?.error || 'Provider request failed', details: data }); } } else if (error.request) { // The request was made but no response was received throw new RCSError('No response received from provider', RCSErrorCode.NETWORK_ERROR, this.name, error); } else { // Something happened in setting up the request that triggered an Error throw new RCSError('Request setup failed', RCSErrorCode.PROVIDER_ERROR, this.name, error); } }); } /** * Make an authenticated HTTP request */ async makeRequest(url, options = {}) { try { const response = await this.http.request({ url, ...options }); return response.data; } catch (error) { // Axios interceptors will handle this error throw error; } } } // Phone number validation regex patterns const PHONE_PATTERNS = { E164: /^\+[1-9]\d{1,14}$/, US: /^(\+1)?[2-9]\d{2}[2-9](?!11)\d{6}$/, GENERAL: /^[\d\s\-\(\)\+]+$/ }; /** * Validates if a phone number is in E.164 format */ function isValidE164(phoneNumber) { return PHONE_PATTERNS.E164.test(phoneNumber); } /** * Formats a phone number to E.164 format */ function formatPhoneNumber(phoneNumber, countryCode = 'US') { // Remove all non-digit characters except + const cleaned = phoneNumber.replace(/[^\d+]/g, ''); // If already in E.164 format, return as is if (isValidE164(cleaned)) { return cleaned; } // Handle US numbers if (countryCode === 'US') { const digitsOnly = cleaned.replace(/^\+/, ''); // US number without country code if (digitsOnly.length === 10) { return `+1${digitsOnly}`; } // US number with country code if (digitsOnly.length === 11 && digitsOnly.startsWith('1')) { return `+${digitsOnly}`; } } return null; } /** * Message builder for creating RCS messages */ class MessageBuilder { message = {}; constructor(to) { if (to) { this.message.to = to; } this.message.content = {}; } to(phoneNumber) { this.message.to = phoneNumber; return this; } setText(text) { if (!this.message.content) { this.message.content = {}; } this.message.content.text = text; return this; } addMedia(url, type, thumbnailUrl) { if (!this.message.content) { this.message.content = {}; } this.message.content.media = { url, type, thumbnailUrl }; return this; } addReply(text, postbackData) { if (!this.message.suggestions) { this.message.suggestions = []; } this.message.suggestions.push({ type: 'reply', text, postbackData: postbackData || text }); return this; } addAction(text, actionType, data) { if (!this.message.suggestions) { this.message.suggestions = []; } const suggestion = { type: 'action', text, action: { type: actionType, data } }; this.message.suggestions.push(suggestion); return this; } setMetadata(metadata) { this.message.metadata = metadata; return this; } build() { if (!this.message.to) { throw new Error('Recipient phone number is required'); } if (!this.message.content || (!this.message.content.text && !this.message.content.media && !this.message.content.richCard)) { throw new Error('Message must have content'); } return this.message; } } /** * Suggestion builder for creating suggestions */ class SuggestionBuilder { static reply(text, postbackData) { return { type: 'reply', text, postbackData: postbackData || text }; } static action(text, actionType, data) { return { type: 'action', text, action: { type: actionType, data } }; } static dial(text, phoneNumber) { return this.action(text, 'dial', phoneNumber); } static openUrl(text, url) { return this.action(text, 'openUrl', url); } static shareLocation(text) { return this.action(text, 'shareLocation'); } static createCalendarEvent(text, eventData) { return this.action(text, 'createCalendarEvent', eventData); } } class LongearsRCSProvider extends BaseProvider { name = 'longears'; apiEndpoint; initialized = false; constructor(auth, config) { super(auth, config); // Set API endpoint from config or use default this.apiEndpoint = config.apiEndpoint || 'https://api.longears.mobi/v1'; // Remove trailing slash if present if (this.apiEndpoint.endsWith('/')) { this.apiEndpoint = this.apiEndpoint.slice(0, -1); } } /** * Initialize the provider */ async initialize(config) { if (this.initialized) { return; } try { logger.info('Initializing Longears RCS provider'); // Update config if provided if (config.apiEndpoint) { this.apiEndpoint = config.apiEndpoint; if (this.apiEndpoint.endsWith('/')) { this.apiEndpoint = this.apiEndpoint.slice(0, -1); } } // Test connection to validate credentials await this.testConnection(); this.initialized = true; logger.info('Longears RCS provider initialized successfully'); } catch (error) { logger.error('Failed to initialize Longears RCS provider:', error); throw new RCSError('Failed to initialize Longears RCS provider', RCSErrorCode.INITIALIZATION_FAILED, this.name, error); } } /** * Send an RCS message */ async sendMessage(message) { if (!this.initialized) { throw new RCSError('Provider not initialized. Call initialize() first.', RCSErrorCode.NOT_INITIALIZED, this.name); } // Validate phone number if (!isValidE164(message.to)) { throw new RCSError('Invalid phone number format. Must be in E.164 format.', RCSErrorCode.INVALID_PHONE_NUMBER, this.name, { phoneNumber: message.to }); } try { logger.debug('Sending message via Longears RCS', { to: message.to }); // Transform the message to Longears format const longearsMessage = this.transformMessage(message); // Send the message const response = await this.makeRequest(`${this.apiEndpoint}/messages`, { method: 'POST', data: longearsMessage }); logger.info('Message sent successfully via Longears RCS', { messageId: response.messageId }); // Transform and return the response return { messageId: response.messageId, status: response.status === 'success' ? 'sent' : 'pending', timestamp: response.timestamp ? new Date(response.timestamp) : new Date(), providerResponse: response }; } catch (error) { logger.error('Failed to send message via Longears RCS:', error); if (error instanceof RCSError) { throw error; } throw new RCSError('Failed to send message via Longears RCS', RCSErrorCode.MESSAGE_SEND_FAILED, this.name, error); } } /** * Check if a phone number is capable of receiving RCS messages * @param phoneNumber The phone number to check in E.164 format * @param options Optional parameters including agentId */ async validatePhoneNumber(phoneNumber, options) { if (!this.initialized) { throw new RCSError('Provider not initialized. Call initialize() first.', RCSErrorCode.NOT_INITIALIZED, this.name); } try { const agentId = options?.agentId; logger.debug('Checking capabilities via Longears RCS', { phoneNumber, ...(agentId ? { agentId } : {}) }); // Try to format the number first const formattedNumber = formatPhoneNumber(phoneNumber) || phoneNumber; // Build the request URL with query parameters let requestUrl = `${this.apiEndpoint}/capabilities?phoneNumber=${encodeURIComponent(formattedNumber)}`; // Add agentId if provided if (agentId) { requestUrl += `&agentId=${encodeURIComponent(agentId)}`; } // Get capabilities for this phone number const response = await this.makeRequest(requestUrl, { method: 'GET' }); logger.debug('Phone number capability result from Longears RCS', { phoneNumber, isRcsSupported: response.isRcsSupported }); // Transform response to match the expected format const features = []; if (response.features) { // Rich card capabilities if (response.features.richCards) features.push('RICHCARD_STANDALONE'); if (response.features.carousels) features.push('RICHCARD_CAROUSEL'); // Action capabilities if (response.features.suggestions) { features.push('ACTION_DIAL'); features.push('ACTION_OPEN_URL'); features.push('ACTION_OPEN_URL_IN_WEBVIEW'); features.push('ACTION_SHARE_LOCATION'); features.push('ACTION_VIEW_LOCATION'); features.push('ACTION_CREATE_CALENDAR_EVENT'); // Note: ACTION_COMPOSE is being deprecated by June 2025 // Only add if explicitly supported by the provider if (response.features.supportedMediaTypes?.includes('compose')) { features.push('ACTION_COMPOSE'); } } } return { success: true, capability: { phoneNumber: formattedNumber, isCapable: response.isRcsSupported, features: features, timestamp: new Date().toISOString() } }; } catch (error) { logger.error('Failed to check capabilities via Longears RCS:', error); if (error instanceof RCSError) { throw error; } return { success: false, error: `Failed to check RCS capabilities: ${error instanceof Error ? error.message : String(error)}` }; } } /** * Test connection to Longears API */ async testConnection() { try { await this.makeRequest(`${this.apiEndpoint}/status`, { method: 'GET' }); } catch (error) { throw new RCSError('Failed to connect to Longears API', RCSErrorCode.NETWORK_ERROR, this.name, error); } } /** * Transform RCS SDK message to Longears format */ transformMessage(message) { const longearsMessage = { destination: message.to, metadata: message.metadata || {} }; // Add text content if (message.content.text) { longearsMessage.text = message.content.text; } // Add media content if (message.content.media) { longearsMessage.media = { url: message.content.media.url, type: message.content.media.type, thumbnailUrl: message.content.media.thumbnailUrl, mimeType: message.content.media.mimeType, fileName: message.content.media.fileName }; } // Add rich card content if (message.content.richCard) { const richCard = message.content.richCard; if (richCard.type === 'standalone') { longearsMessage.richCard = { type: 'standalone', title: richCard.title, description: richCard.description, media: richCard.media, orientation: richCard.orientation || 'vertical', suggestions: richCard.suggestions }; } else if (richCard.type === 'carousel') { longearsMessage.richCard = { type: 'carousel', cards: richCard.cards, width: richCard.width || 'medium' }; } } // Add suggestions if (message.suggestions && message.suggestions.length > 0) { longearsMessage.suggestions = message.suggestions.map(suggestion => ({ type: suggestion.type, text: suggestion.text, postbackData: suggestion.postbackData, action: suggestion.action })); } return longearsMessage; } } /** * Factory function to create provider instances */ function createProvider(providerName, auth, config) { switch (providerName.toLowerCase()) { case 'longears': case 'longears-rcs': return new LongearsRCSProvider(auth, config); default: throw new RCSError(`Unknown provider: ${providerName}`, RCSErrorCode.PROVIDER_NOT_FOUND, providerName); } } class LongearsAuth { type = 'custom'; credentials; token = null; tokenExpiresAt = null; constructor(credentials) { if (!credentials.apiKey || !credentials.apiSecret) { throw new RCSError('Longears authentication requires apiKey and apiSecret', RCSErrorCode.AUTH_INVALID, 'longears'); } this.credentials = credentials; } /** * Authenticate with Longears API * This implementation uses HMAC authentication */ async authenticate() { try { logger.debug('Authenticating with Longears'); // If we already have a valid token, return it if (this.isValid()) { logger.debug('Using existing Longears token'); return this.token; } // Generate a timestamp for the request const timestamp = Date.now().toString(); // Create HMAC signature const signature = this.generateSignature(timestamp); // Create token object with expiration date const expiresAt = new Date(Date.now() + 3600 * 1000); // Token valid for 1 hour this.token = { token: signature, type: 'Custom', expiresAt }; this.tokenExpiresAt = expiresAt; logger.debug('Longears authentication successful'); return this.token; } catch (error) { logger.error('Longears authentication failed:', error); throw new RCSError('Failed to authenticate with Longears', RCSErrorCode.AUTH_FAILED, 'longears', error); } } /** * Refresh the token * For Longears, we simply regenerate the signature */ async refresh() { logger.debug('Refreshing Longears authentication'); this.token = null; this.tokenExpiresAt = null; return this.authenticate(); } /** * Check if the current token is valid */ isValid() { if (!this.token || !this.tokenExpiresAt) { return false; } // Token is valid if it expires in the future const now = new Date(); return now < this.tokenExpiresAt; } /** * Generate HMAC signature for Longears authentication */ generateSignature(timestamp) { // The message to sign is the API key combined with the timestamp const message = `${this.credentials.apiKey}:${timestamp}`; // Create HMAC signature using the API secret const hmac = crypto.createHmac('sha256', this.credentials.apiSecret); hmac.update(message); const signature = hmac.digest('hex'); // Format as a header value: {apiKey}:{timestamp}:{signature} return `${this.credentials.apiKey}:${timestamp}:${signature}`; } /** * Get authentication headers for requests */ getAuthHeaders() { if (!this.token) { throw new RCSError('No valid token available. Call authenticate() first.', RCSErrorCode.AUTH_FAILED, 'longears'); } return { 'X-Longears-Auth': this.token.token }; } } /** * Factory function to create auth provider instances */ function createAuthProvider(config) { switch (config.type) { case 'longears': return new LongearsAuth(config.credentials); default: throw new RCSError(`Unknown auth type: ${config.type}`, RCSErrorCode.AUTH_INVALID, config.type); } } class RCSClient { provider; auth; config; initialized = false; constructor(config) { this.config = config; this.auth = createAuthProvider(config.auth); this.provider = createProvider(config.provider, this.auth, config.options || {}); } /** * Initialize the RCS client and authenticate with the provider */ async initialize() { if (this.initialized) { return; } try { logger.info(`Initializing RCS client with provider: ${this.config.provider}`); // Authenticate first await this.auth.authenticate(); // Initialize provider await this.provider.initialize(this.config.options || {}); this.initialized = true; logger.info('RCS client initialized successfully'); } catch (error) { logger.error('Failed to initialize RCS client:', error); throw new RCSError('Failed to initialize RCS client', RCSErrorCode.INITIALIZATION_FAILED, this.config.provider, error); } } /** * Send an RCS message */ async sendMessage(options) { this.ensureInitialized(); try { logger.debug('Sending RCS message:', { to: options.to, provider: this.config.provider }); const response = await this.provider.sendMessage({ to: options.to, content: options.content, suggestions: options.suggestions, metadata: options.metadata }); logger.info('Message sent successfully:', { messageId: response.messageId }); return response; } catch (error) { logger.error('Failed to send message:', error); if (error instanceof RCSError) { throw error; } throw new RCSError('Failed to send message', RCSErrorCode.MESSAGE_SEND_FAILED, this.config.provider, error); } } /** * Check if a phone number is capable of receiving RCS messages * @param phoneNumber The phone number to check in E.164 format * @param options Optional parameters including agentId for Google RBM */ async validatePhoneNumber(phoneNumber, options) { this.ensureInitialized(); try { // Use either provided agentId or the one from config const agentId = options?.agentId || this.config.options?.agentId; logger.debug('Checking RCS capability for phone number:', phoneNumber, agentId ? { agentId } : {}); return await this.provider.validatePhoneNumber(phoneNumber, { agentId }); } catch (error) { logger.error('Failed to check RCS capability:', error); if (error instanceof RCSError) { throw error; } return { success: false, error: `Failed to check RCS capability: ${error instanceof Error ? error.message : String(error)}` }; } } /** * Get the current provider name */ getProvider() { return this.provider.name; } /** * Check if the client is initialized */ isInitialized() { return this.initialized; } /** * Ensure the client is initialized before making calls */ ensureInitialized() { if (!this.initialized) { throw new RCSError('RCS client not initialized. Call initialize() first.', RCSErrorCode.NOT_INITIALIZED, this.config.provider); } } } export { MessageBuilder, RCSClient, RCSError, RCSErrorCode, SuggestionBuilder, formatPhoneNumber, isValidE164 }; //# sourceMappingURL=index.esm.js.map