UNPKG

@profullstack/lead-generator

Version:

A CLI tool for sending mass lead emails to prospective clients from CSV files with AI-powered personalization

357 lines (304 loc) 11 kB
/** * SMS Service - Handles SMS sending via Twilio * Provides batch SMS functionality with rate limiting and error handling */ import twilio from 'twilio'; import { updateCSVStatus, SMS_STATUS } from './csv-status-tracker.js'; import { personalizeSmsTemplate } from './sms-templates/index.js'; /** * SMS Service class for managing Twilio SMS operations */ export class SmsService { constructor(config = {}) { this.config = { accountSid: config.accountSid || process.env.TWILIO_ACCOUNT_SID, authToken: config.authToken || process.env.TWILIO_AUTH_TOKEN, fromNumber: config.fromNumber || process.env.TWILIO_FROM_NUMBER, dryRun: config.dryRun || false, maxRetries: config.maxRetries || 3, retryDelay: config.retryDelay || 1000, ...config }; this.validateConfig(); if (!this.config.dryRun) { this.client = twilio(this.config.accountSid, this.config.authToken); } } /** * Validate required configuration */ validateConfig() { if (!this.config.dryRun) { if (!this.config.accountSid) { throw new Error('Twilio Account SID is required'); } if (!this.config.authToken) { throw new Error('Twilio Auth Token is required'); } if (!this.config.fromNumber) { throw new Error('Twilio From Number is required'); } } } /** * Validate phone number format * @param {string} phoneNumber - Phone number to validate * @returns {boolean} True if valid */ validatePhoneNumber(phoneNumber) { if (!phoneNumber) return false; // Remove all non-digit characters const cleaned = phoneNumber.replace(/\D/g, ''); // Check if it's a valid length (10-15 digits) if (cleaned.length < 10 || cleaned.length > 15) return false; // Must start with country code or area code return /^(\+?1)?[2-9]\d{9}$/.test(phoneNumber) || /^\+\d{10,14}$/.test(phoneNumber) || /^[2-9]\d{9}$/.test(cleaned); } /** * Format phone number for Twilio * @param {string} phoneNumber - Raw phone number * @returns {string} Formatted phone number */ formatPhoneNumber(phoneNumber) { if (!phoneNumber) return null; // Handle already formatted numbers if (phoneNumber.startsWith('+')) { return phoneNumber; // Return as-is if already properly formatted } // Remove all non-digit characters let cleaned = phoneNumber.replace(/\D/g, ''); // Add country code if missing (assume US) if (cleaned.length === 10) { cleaned = '1' + cleaned; } return '+' + cleaned; } /** * Validate SMS data before sending * @param {Object} smsData - SMS data to validate * @returns {Object} Validation result */ validateSmsData(smsData) { const errors = []; if (!smsData.to) { errors.push('Recipient phone number is required'); } else if (!this.validatePhoneNumber(smsData.to)) { errors.push('Invalid recipient phone number format'); } if (!smsData.message) { errors.push('SMS message is required'); } else if (smsData.message.length > 1600) { errors.push('SMS message too long (max 1600 characters)'); } return { isValid: errors.length === 0, errors }; } /** * Send a single SMS * @param {Object} smsData - SMS data * @returns {Promise<Object>} Send result */ async sendSms(smsData) { try { // Validate SMS data const validation = this.validateSmsData(smsData); if (!validation.isValid) { throw new Error(`SMS validation failed: ${validation.errors.join(', ')}`); } const formattedTo = this.formatPhoneNumber(smsData.to); const messageData = { body: smsData.message, from: this.config.fromNumber, to: formattedTo }; if (this.config.dryRun) { console.log(`📱 [DRY RUN] Would send SMS to ${formattedTo}:`); console.log(` Message: ${smsData.message.substring(0, 100)}${smsData.message.length > 100 ? '...' : ''}`); return { success: true, messageId: `dry-run-${Date.now()}`, to: formattedTo, message: smsData.message, cost: 0, dryRun: true }; } // Send SMS via Twilio const message = await this.client.messages.create(messageData); return { success: true, messageId: message.sid, to: formattedTo, message: smsData.message, status: message.status, cost: message.price || 0, dryRun: false }; } catch (error) { console.error(`❌ SMS sending failed:`, error.message); return { success: false, error: error.message, to: smsData.to, message: smsData.message, dryRun: this.config.dryRun }; } } /** * Send SMS with retry logic * @param {Object} smsData - SMS data * @param {number} retryCount - Current retry count * @returns {Promise<Object>} Send result */ async sendSmsWithRetry(smsData, retryCount = 0) { try { const result = await this.sendSms(smsData); if (!result.success && retryCount < this.config.maxRetries) { console.log(`🔄 Retrying SMS send (attempt ${retryCount + 1}/${this.config.maxRetries})`); await new Promise(resolve => setTimeout(resolve, this.config.retryDelay * (retryCount + 1))); return this.sendSmsWithRetry(smsData, retryCount + 1); } return result; } catch (error) { if (retryCount < this.config.maxRetries) { console.log(`🔄 Retrying SMS send due to error (attempt ${retryCount + 1}/${this.config.maxRetries})`); await new Promise(resolve => setTimeout(resolve, this.config.retryDelay * (retryCount + 1))); return this.sendSmsWithRetry(smsData, retryCount + 1); } throw error; } } /** * Send batch SMS messages * @param {Array} smsDataArray - Array of SMS data objects * @param {Object} options - Batch options * @returns {Promise<Object>} Batch results */ async sendBatchSms(smsDataArray, options = {}) { const { batchSize = 10, delayBetweenBatches = 1000, csvFile = null, onProgress = null } = options; const results = []; const stats = { total: smsDataArray.length, sent: 0, failed: 0, totalCost: 0, startTime: new Date(), endTime: null }; console.log(`📱 Starting SMS batch send: ${stats.total} messages`); // Process in batches for (let i = 0; i < smsDataArray.length; i += batchSize) { const batch = smsDataArray.slice(i, i + batchSize); const batchNumber = Math.floor(i / batchSize) + 1; const totalBatches = Math.ceil(smsDataArray.length / batchSize); console.log(`Processing batch ${batchNumber}: ${batch.length} SMS messages (${i + 1}-${Math.min(i + batch.length, smsDataArray.length)} of ${smsDataArray.length})`); // Send batch concurrently const batchPromises = batch.map(async (smsData, index) => { try { const result = await this.sendSmsWithRetry(smsData); // Update CSV status if file provided if (csvFile && smsData.leadData) { const status = result.success ? SMS_STATUS.SENT : SMS_STATUS.FAILED; await updateCSVStatus(csvFile, smsData.leadData, { smsStatus: status }); } // Update stats if (result.success) { stats.sent++; stats.totalCost += parseFloat(result.cost || 0); } else { stats.failed++; } // Call progress callback if (onProgress) { onProgress({ current: i + index + 1, total: stats.total, result, stats: { ...stats } }); } return result; } catch (error) { console.error(`❌ Batch SMS error for ${smsData.to}:`, error.message); stats.failed++; return { success: false, error: error.message, to: smsData.to, message: smsData.message }; } }); const batchResults = await Promise.all(batchPromises); results.push(...batchResults); // Add delay between batches (except for last batch) if (i + batchSize < smsDataArray.length && delayBetweenBatches > 0) { console.log(`Waiting ${delayBetweenBatches}ms before next batch...`); await new Promise(resolve => setTimeout(resolve, delayBetweenBatches)); } } stats.endTime = new Date(); stats.duration = stats.endTime - stats.startTime; console.log(`📊 SMS Batch Results:`); console.log(` Total: ${stats.total}`); console.log(` Sent: ${stats.sent}`); console.log(` Failed: ${stats.failed}`); console.log(` Success Rate: ${((stats.sent / stats.total) * 100).toFixed(1)}%`); console.log(` Total Cost: $${stats.totalCost.toFixed(4)}`); console.log(` Duration: ${(stats.duration / 1000).toFixed(1)}s`); return { results, stats, success: stats.failed === 0 }; } } /** * Create SMS from template * @param {Object} template - SMS template * @param {Object} leadData - Lead data * @param {Object} senderInfo - Sender information * @param {Object} options - Options * @returns {Object} SMS data ready to send */ export function createSmsFromTemplate(template, leadData, senderInfo = {}, options = {}) { const personalizedSms = personalizeSmsTemplate(template, leadData, senderInfo, options); return { to: leadData.phone || leadData.phoneNumber || leadData.mobile, message: personalizedSms.message, template: personalizedSms.template, leadData: leadData }; } /** * Send batch SMS from templates * @param {Array} leads - Array of lead data * @param {Array} templates - Array of SMS templates * @param {Object} senderInfo - Sender information * @param {Object} options - Options * @returns {Promise<Object>} Batch results */ export async function sendBatchSmsFromTemplates(leads, templates, senderInfo = {}, options = {}) { const smsService = new SmsService(options.smsConfig || {}); // Create SMS data from templates const smsDataArray = leads.map(lead => { // Select template (random or specified) const template = options.templateSelector ? options.templateSelector(templates, lead) : templates[Math.floor(Math.random() * templates.length)]; return createSmsFromTemplate(template, lead, senderInfo, options); }).filter(smsData => smsData.to); // Filter out leads without phone numbers if (smsDataArray.length === 0) { throw new Error('No valid phone numbers found in leads data'); } return smsService.sendBatchSms(smsDataArray, options); } export default SmsService;