UNPKG

@profullstack/lead-generator

Version:

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

574 lines (493 loc) 18 kB
/** * Bland.ai Service Module * Handles Bland.ai integration for automated voice calling */ import dotenv from 'dotenv'; import fs from 'fs'; import path from 'path'; import { getRandomVoiceTemplate, getVoiceTemplateById, personalizeVoiceScript } from './voice-templates/index.js'; dotenv.config(); /** * Bland.ai Service class for voice calling integration */ export class BlandAIService { constructor(config = {}) { this.config = { apiKey: config.apiKey || process.env.BLAND_AI_API_KEY, phoneNumber: config.phoneNumber || process.env.BLAND_AI_PHONE_NUMBER, baseUrl: config.baseUrl || process.env.BLAND_AI_BASE_URL || 'https://api.bland.ai', webhookUrl: config.webhookUrl || process.env.VOICE_WEBHOOK_URL, companyName: config.companyName || process.env.COMPANY_NAME || 'Our Company', callerName: config.callerName || process.env.CALLER_NAME || 'Sales Representative', calendlyLink: config.calendlyLink || process.env.CALENDLY_LINK, callHoursStart: config.callHoursStart || process.env.CALL_HOURS_START || '09:00', callHoursEnd: config.callHoursEnd || process.env.CALL_HOURS_END || '17:00', timezone: config.timezone || process.env.CALL_TIMEZONE || 'America/New_York', maxAttempts: config.maxAttempts || parseInt(process.env.MAX_CALL_ATTEMPTS) || 3, retryDelayHours: config.retryDelayHours || parseInt(process.env.CALL_RETRY_DELAY_HOURS) || 24, ...config }; if (!this.config.apiKey) { throw new Error('Bland.ai API key is required'); } if (!this.config.phoneNumber) { throw new Error('Bland.ai phone number is required'); } // Initialize call tracking this.activeCalls = new Map(); this.callHistory = []; this.doNotCallList = new Set(); } /** * Validate phone number format * @param {string} phoneNumber - Phone number to validate * @returns {boolean} True if phone number is valid */ isValidPhoneNumber(phoneNumber) { if (!phoneNumber || typeof phoneNumber !== 'string') { return false; } // Remove all non-digit characters except + const cleaned = phoneNumber.replace(/[^\d\+]/g, ''); // Check for valid formats: +1234567890, 1234567890, or 234567890 const phoneRegex = /^(\+?1?)?[2-9]\d{2}[2-9]\d{2}\d{4}$/; return phoneRegex.test(cleaned); } /** * Format phone number for Bland.ai API * @param {string} phoneNumber - Phone number to format * @returns {string} Formatted phone number */ formatPhoneNumber(phoneNumber) { if (!phoneNumber) return null; // Remove all non-digit characters except + const cleaned = phoneNumber.replace(/[^\d\+]/g, ''); // Add +1 for US numbers if not present if (cleaned.length === 10 && !cleaned.startsWith('+')) { return `+1${cleaned}`; } if (cleaned.length === 11 && cleaned.startsWith('1') && !cleaned.startsWith('+')) { return `+${cleaned}`; } return cleaned.startsWith('+') ? cleaned : `+${cleaned}`; } /** * Create personalized voice script for contact using template system * @param {Object} contact - Contact information * @param {Object} options - Script options (templateId, etc.) * @returns {string} Voice script */ createVoiceScript(contact, options = {}) { try { // Get template - use specified template or random one let template; if (options.templateId) { template = getVoiceTemplateById(options.templateId); if (!template) { console.warn(`Template ${options.templateId} not found, using random template`); template = getRandomVoiceTemplate(); } } else { template = getRandomVoiceTemplate(); } // Create config object for personalization const config = { callerName: this.config.callerName, companyName: this.config.companyName, calendlyLink: this.config.calendlyLink }; // Personalize the script const personalizedScript = personalizeVoiceScript(template, contact, config); return personalizedScript; } catch (error) { console.error('Error creating voice script:', error); // Fallback to simple script if template system fails const firstName = contact.Name?.split(' ')[0] || 'there'; return `Hello ${firstName}, this is ${this.config.callerName} calling from ${this.config.companyName}. I'm an AI assistant reaching out about potential opportunities that might interest you. This call will take just a moment. If you'd like me to send you a link to schedule a brief conversation with our team, please press 1 on your keypad. If you'd prefer not to receive future calls from us, please press 2 and we'll remove you from our calling list. Thank you for your time, ${firstName}.`; } } /** * Check if current time is within allowed call hours (9 AM - 5 PM, Monday-Friday) * @param {Date} testDate - Optional date for testing (defaults to current time) * @returns {boolean} True if within business call hours */ isWithinCallHours(testDate = null) { try { const now = testDate || new Date(); const timeZone = this.config.timezone; // Get current day of week and time in the specified timezone const currentDateTime = new Intl.DateTimeFormat('en-US', { timeZone, hour12: false, hour: '2-digit', minute: '2-digit', weekday: 'long' }).formatToParts(now); // Extract parts const dayOfWeek = currentDateTime.find(part => part.type === 'weekday')?.value; const hour = parseInt(currentDateTime.find(part => part.type === 'hour')?.value); const minute = parseInt(currentDateTime.find(part => part.type === 'minute')?.value); // Check if it's a weekday (Monday-Friday) const weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']; if (!weekdays.includes(dayOfWeek)) { return false; // No calls on weekends } // Convert current time to minutes since midnight const currentMinutes = hour * 60 + minute; // Parse configured call hours (default: 9 AM - 5 PM) const [startHour, startMinute] = this.config.callHoursStart.split(':').map(Number); const startMinutes = startHour * 60 + startMinute; const [endHour, endMinute] = this.config.callHoursEnd.split(':').map(Number); const endMinutes = endHour * 60 + endMinute; // Check if current time is within business hours const withinHours = currentMinutes >= startMinutes && currentMinutes <= endMinutes; if (!withinHours) { console.log(`🕐 Outside business hours: Current time is ${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')} ${dayOfWeek} (${timeZone}), business hours are ${this.config.callHoursStart}-${this.config.callHoursEnd} Monday-Friday`); } return withinHours; } catch (error) { console.warn('Error checking call hours:', error.message); return false; // Default to NOT allowing calls if time check fails (safer approach) } } /** * Check if phone number is on do-not-call list * @param {string} phoneNumber - Phone number to check * @param {Array} doNotCallList - List of numbers to avoid * @returns {boolean} True if on do-not-call list */ isOnDoNotCallList(phoneNumber, doNotCallList = []) { const formatted = this.formatPhoneNumber(phoneNumber); return doNotCallList.includes(formatted) || this.doNotCallList.has(formatted); } /** * Make a voice call using Bland.ai API * @param {Object} contact - Contact information * @param {Object} options - Call options * @returns {Promise<Object>} Call result */ async makeCall(contact, options = {}) { try { // Handle dry run mode if (options.dryRun || process.env.VOICE_DRY_RUN === 'true') { return { success: true, dryRun: true, message: 'Voice call simulation - not actually made', contact, callId: `dry-run-${Date.now()}`, status: 'simulated' }; } // Validate contact data if (!contact.Name) { return { success: false, error: 'Contact name is required', contact }; } if (!this.isValidPhoneNumber(contact.PhoneNumber)) { return { success: false, error: 'Invalid phone number format', contact }; } // Check compliance if (!this.isWithinCallHours()) { return { success: false, error: 'Outside of allowed call hours', contact }; } if (this.isOnDoNotCallList(contact.PhoneNumber, options.doNotCallList)) { return { success: false, error: 'Phone number is on do-not-call list', contact }; } // Format phone number const formattedPhone = this.formatPhoneNumber(contact.PhoneNumber); // Create voice script using template system const script = this.createVoiceScript(contact, options); // Prepare API request const requestBody = { phone_number: formattedPhone, from: this.config.phoneNumber, task: script, voice: 'maya', // Default Bland.ai voice language: 'en', webhook: this.config.webhookUrl, wait_for_greeting: true, record: true, interruption_threshold: 100, max_duration: 300, // 5 minutes max answered_by_enabled: true, metadata: { contact_name: contact.Name, contact_email: contact.Email || '', campaign_id: options.campaignId || 'default', timestamp: new Date().toISOString() } }; // Make API call to Bland.ai const response = await fetch(`${this.config.baseUrl}/v1/calls`, { method: 'POST', headers: { 'Authorization': `Bearer ${this.config.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); const responseData = await response.json(); if (!response.ok) { return { success: false, error: `Bland.ai API error: ${responseData.error || response.statusText}`, status: response.status, contact }; } // Track active call const callResult = { success: true, callId: responseData.call_id, status: responseData.status || 'queued', message: responseData.message || 'Call initiated successfully', contact, timestamp: new Date().toISOString(), phoneNumber: formattedPhone }; this.activeCalls.set(responseData.call_id, callResult); return callResult; } catch (error) { return { success: false, error: `Call failed: ${error.message}`, contact }; } } /** * Get call status from Bland.ai API * @param {string} callId - Call ID to check * @returns {Promise<Object>} Call status */ async getCallStatus(callId) { try { const response = await fetch(`${this.config.baseUrl}/v1/calls/${callId}`, { headers: { 'Authorization': `Bearer ${this.config.apiKey}` } }); const responseData = await response.json(); if (!response.ok) { return { success: false, error: `Failed to get call status: ${responseData.error || response.statusText}`, callId }; } return { success: true, callId, status: responseData.status, duration: responseData.duration, outcome: responseData.outcome, transcript: responseData.transcript, userInput: responseData.user_input, ...responseData }; } catch (error) { return { success: false, error: `Status check failed: ${error.message}`, callId }; } } /** * Process webhook data from Bland.ai * @param {Object} webhookData - Webhook payload * @returns {Object} Processed webhook data */ processWebhookData(webhookData) { try { if (!webhookData.call_id) { return { error: 'Invalid webhook data: missing call_id' }; } const processed = { callId: webhookData.call_id, status: webhookData.status, duration: webhookData.duration, outcome: webhookData.outcome, transcript: webhookData.transcript, userChoice: webhookData.user_input, timestamp: new Date().toISOString(), rawData: webhookData }; // Update active call tracking if (this.activeCalls.has(webhookData.call_id)) { const callData = this.activeCalls.get(webhookData.call_id); Object.assign(callData, processed); // Move to history if call is completed if (['completed', 'failed', 'no-answer'].includes(webhookData.status)) { this.callHistory.push(callData); this.activeCalls.delete(webhookData.call_id); } } return processed; } catch (error) { return { error: `Webhook processing failed: ${error.message}`, rawData: webhookData }; } } /** * Add phone number to do-not-call list * @param {string} phoneNumber - Phone number to add */ addToDoNotCallList(phoneNumber) { const formatted = this.formatPhoneNumber(phoneNumber); this.doNotCallList.add(formatted); } /** * Get call statistics * @returns {Object} Call statistics */ getCallStats() { const allCalls = [...this.activeCalls.values(), ...this.callHistory]; const stats = { total: allCalls.length, active: this.activeCalls.size, completed: 0, failed: 0, answered: 0, voicemail: 0, optedOut: 0, scheduledMeetings: 0, totalDuration: 0 }; for (const call of allCalls) { if (call.status === 'completed') stats.completed++; if (call.status === 'failed') stats.failed++; if (call.outcome === 'answered') stats.answered++; if (call.outcome === 'voicemail') stats.voicemail++; if (call.userChoice === '2') stats.optedOut++; if (call.userChoice === '1') stats.scheduledMeetings++; if (call.duration) stats.totalDuration += call.duration; } stats.averageDuration = stats.completed > 0 ? Math.round(stats.totalDuration / stats.completed) : 0; stats.answerRate = stats.total > 0 ? ((stats.answered / stats.total) * 100).toFixed(2) : 0; stats.conversionRate = stats.answered > 0 ? ((stats.scheduledMeetings / stats.answered) * 100).toFixed(2) : 0; return stats; } } /** * Make single voice call * @param {Object} contact - Contact information * @param {Object} options - Call options * @returns {Promise<Object>} Call result */ export async function makeVoiceCall(contact, options = {}) { const blandAIService = new BlandAIService(options); return await blandAIService.makeCall(contact, options); } /** * Start call campaign for multiple contacts * @param {Array} contacts - Array of contact objects * @param {Object} options - Campaign options * @returns {Promise<Object>} Campaign results */ export async function startCallCampaign(contacts, options = {}) { const blandAIService = new BlandAIService(options); const delay = options.delay || 5000; // 5 second delay between calls const results = []; let successful = 0; let failed = 0; console.log(`📞 Starting voice campaign for ${contacts.length} contacts...`); for (let i = 0; i < contacts.length; i++) { const contact = contacts[i]; try { console.log(`📞 Calling ${contact.Name} (${i + 1}/${contacts.length})...`); const result = await blandAIService.makeCall(contact, { ...options, campaignId: options.campaignId || `campaign-${Date.now()}` }); if (result.success) { successful++; console.log(`✅ Call initiated for ${contact.Name} - Call ID: ${result.callId}`); } else { failed++; console.log(`❌ Call failed for ${contact.Name}: ${result.error}`); } results.push({ ...result, contactIndex: i + 1 }); // Add delay between calls (except for last call) if (i < contacts.length - 1 && delay > 0) { console.log(`⏳ Waiting ${delay}ms before next call...`); await new Promise(resolve => setTimeout(resolve, delay)); } } catch (error) { failed++; const errorResult = { success: false, error: error.message, contact, contactIndex: i + 1 }; results.push(errorResult); console.log(`❌ Call failed for ${contact.Name}: ${error.message}`); } } const campaignResults = { successful, failed, total: contacts.length, results, successRate: contacts.length > 0 ? ((successful / contacts.length) * 100).toFixed(2) : 0, timestamp: new Date().toISOString(), campaignId: options.campaignId || `campaign-${Date.now()}` }; console.log(`📊 Campaign completed: ${successful} successful, ${failed} failed`); return campaignResults; } /** * Save campaign results to file * @param {Object} campaignResults - Campaign results to save * @param {string} filePath - File path to save results */ export function saveCampaignResults(campaignResults, filePath) { try { const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(filePath, JSON.stringify(campaignResults, null, 2)); console.log(`💾 Campaign results saved to: ${filePath}`); } catch (error) { console.error(`❌ Failed to save campaign results: ${error.message}`); } } export default { BlandAIService, makeVoiceCall, startCallCampaign, saveCampaignResults };