autoresponder.js
1 #!/usr/bin/env node 2 3 /** 4 * Autoresponder — LLM-powered reply generator for inbound messages 5 * 6 * Processes unresponded inbound messages, classifies intent, generates 7 * a reply via Claude Opus (OpenRouter), and sends via the appropriate channel. 8 * 9 * Business hours compliance: 10 * - UK: 08:00-21:00 UTC 11 * - US: 08:00-21:00 ET (UTC-5/UTC-4 DST) 12 * - AU: 08:00-21:00 AEDT (UTC+11) 13 */ 14 15 import { readFileSync } from 'fs'; 16 import { join, dirname } from 'path'; 17 import { fileURLToPath } from 'url'; 18 import { getAll, getOne, run } from '../utils/db.js'; 19 import Logger from '../utils/logger.js'; 20 import { logLLMUsage } from '../utils/llm-usage-tracker.js'; 21 import { getCountryByCode } from '../config/countries.js'; 22 import { isBusinessHours, getRecipientTimezone } from '../utils/compliance.js'; 23 import { getScoreDataWithFallback } from '../utils/score-storage.js'; 24 import '../utils/load-env.js'; 25 26 const __filename = fileURLToPath(import.meta.url); 27 const __dirname = dirname(__filename); 28 const projectRoot = join(__dirname, '../..'); 29 30 const logger = new Logger('Autoresponder'); 31 32 /** 33 * Country-specific pricing for Stage 4 (qualified/pricing) — 333Method defaults. 34 * Can be overridden per-call via the `pricing` option on processInboundQueue. 35 */ 36 const DEFAULT_PRICING = { 37 AU: { amount: 337, currency: 'AUD', symbol: '$' }, 38 UK: { amount: 159, currency: 'GBP', symbol: '\u00a3' }, 39 US: { amount: 297, currency: 'USD', symbol: '$' }, 40 CA: { amount: 297, currency: 'CAD', symbol: '$' }, 41 NZ: { amount: 349, currency: 'NZD', symbol: '$' }, 42 }; 43 44 // Keep the legacy name for any callers that imported it directly 45 const PRICING = DEFAULT_PRICING; 46 47 // Exported for unit testing 48 export { DEFAULT_PRICING, getPricing, PROJECT_CONFIG }; 49 50 /** 51 * Resolve pricing for a given country code. 52 * 53 * @param {string} countryCode 54 * @param {Object|Function} [pricingOverride] - Optional override. Either a plain object keyed by 55 * country code (same shape as DEFAULT_PRICING), or a function `(countryCode) => { amount, 56 * currency, symbol }`. Falls back to DEFAULT_PRICING when the override doesn't cover the code. 57 * @returns {{ amount: number, currency: string, symbol: string }} 58 */ 59 function getPricing(countryCode, pricingOverride) { 60 if (pricingOverride) { 61 if (typeof pricingOverride === 'function') { 62 const result = pricingOverride(countryCode); 63 if (result) return result; 64 } else { 65 // eslint-disable-next-line security/detect-object-injection -- Safe: caller-controlled country code 66 const result = pricingOverride[countryCode]; 67 if (result) return result; 68 } 69 } 70 // eslint-disable-next-line security/detect-object-injection -- Safe: limited known country codes 71 return DEFAULT_PRICING[countryCode] || DEFAULT_PRICING.US; 72 } 73 74 /** 75 * Load the autoresponder prompt for the given project. 76 * 77 * @param {string} [project='333method'] - Project identifier ('333method' | '2step') 78 * @returns {string|null} 79 */ 80 function loadPromptFile(project = '333method') { 81 const filename = project === '2step' 82 ? 'prompts/autoresponder-2step.md' 83 : 'prompts/autoresponder.md'; 84 try { 85 return readFileSync(join(projectRoot, filename), 'utf-8'); 86 } catch (_err) { 87 logger.warn(`Could not load ${filename} — using inline prompt`); 88 return null; 89 } 90 } 91 92 /** 93 * Project-specific configuration for LLM context injection. 94 * Each project defines its default identity, value prop description, and 95 * payment URL pattern so generateReply() can build the right system prompt. 96 */ 97 const PROJECT_CONFIG = { 98 '333method': { 99 identity: 'Marcus Webb from Audit & Fix (auditandfix.com)', 100 service: 'website performance audit reports', 101 paymentUrlPrefix: 'auditandfix.com/o/', 102 defaultPrompt: 'You are Marcus from Audit & Fix (auditandfix.com), responding to inbound replies from local business owners. Return JSON: {"reply":"text","skip":false}', 103 }, 104 '2step': { 105 identity: 'Marcus Webb from Audit & Fix Video Reviews (auditandfix.com)', 106 service: 'AI video testimonials created from Google reviews', 107 paymentUrlPrefix: 'auditandfix.com/v/', 108 defaultPrompt: 'You are Marcus from Audit & Fix Video Reviews (auditandfix.com), responding to inbound replies from local business owners about a free video review we made from their Google reviews. Return JSON: {"reply":"text","skip":false}', 109 }, 110 }; 111 112 /** 113 * Determine if we should auto-respond to an inbound message 114 * 115 * @param {Object} inbound - The inbound message row from messages table 116 * @param {string} [messagesTable='messages'] - Table name for messages (supports schema-qualified names) 117 * @returns {Promise<boolean>} 118 */ 119 export async function shouldAutoRespond(inbound, messagesTable = 'messages') { 120 // Kill switch 121 if (process.env.AUTORESPONDER_ENABLED === 'false') { 122 return false; 123 } 124 125 // Skip autoresponder / out-of-office intents 126 const skipIntents = ['autoresponder', 'opt-out']; 127 if (inbound.intent && skipIntents.includes(inbound.intent)) { 128 return false; 129 } 130 131 // Skip if inbound message is older than 72 hours 132 if (inbound.created_at) { 133 const messageAge = Date.now() - new Date(inbound.created_at).getTime(); 134 const SEVENTY_TWO_HOURS = 72 * 60 * 60 * 1000; 135 if (messageAge > SEVENTY_TWO_HOURS) { 136 return false; 137 } 138 } 139 140 // Skip if we already sent an autoresponder reply for this inbound message 141 // Use EXTRACT/AT TIME ZONE to normalise timestamp formats 142 const existingReply = await getOne( 143 `SELECT id FROM ${messagesTable} 144 WHERE site_id = $1 145 AND direction = 'outbound' 146 AND message_type = 'reply' 147 AND sent_at IS NOT NULL 148 AND created_at >= $2`, 149 [inbound.site_id, inbound.created_at] 150 ); 151 152 if (existingReply) { 153 return false; 154 } 155 156 return true; 157 } 158 159 /** 160 * Map raw intent/sentiment to funnel stage 161 */ 162 export function classifyFunnelStage(inbound) { 163 const intent = (inbound.intent || '').toLowerCase(); 164 const sentiment = (inbound.sentiment || '').toLowerCase(); 165 const body = (inbound.message_body || '').toLowerCase(); 166 167 // Stage 6: autoresponder/out-of-office 168 if ( 169 intent === 'autoresponder' || 170 /out of (the )?office|auto[- ]?reply|automated response/i.test(body) 171 ) { 172 return 'autoresponder'; 173 } 174 175 // Stage 5: not interested / STOP / hostile 176 if (intent === 'not-interested' || intent === 'opt-out') { 177 return 'not_interested'; 178 } 179 if (/\b(stop|unsubscribe|remove me|don'?t contact|go away)\b/i.test(body)) { 180 return 'not_interested'; 181 } 182 183 // Stage 4: asks price directly 184 if (intent === 'pricing') { 185 return 'qualified'; 186 } 187 if (/\b(how much|price|cost|what do you charge|pricing|what'?s it cost)\b/i.test(body)) { 188 return 'qualified'; 189 } 190 191 // Stage 3: objection / tell me more / warm follow-up 192 if ( 193 /\b(how does it work|what do you offer|tell me more|is this legit|what'?s included|sample|report)\b/i.test( 194 body 195 ) 196 ) { 197 return 'objection'; 198 } 199 200 // Stage 1: interested / positive 201 if (intent === 'interested' || intent === 'schedule' || sentiment === 'positive') { 202 return 'interested'; 203 } 204 if (/\b(yes|interested|sounds good|sure|ok|great|perfect|let'?s do it|go ahead)\b/i.test(body)) { 205 return 'interested'; 206 } 207 208 // Stage 2: unknown / confused — default 209 return 'unknown'; 210 } 211 212 /** 213 * Build context for the LLM prompt 214 * 215 * @param {Object} inbound - Inbound message row 216 * @param {string} [messagesTable='messages'] - Table name for messages queries 217 * @param {Object|Function} [pricingOverride] - Optional pricing override (see getPricing) 218 * @param {string} [project='333method'] - Project identifier ('333method' | '2step') 219 * @returns {Promise<Object>} 220 */ 221 export async function buildContext(inbound, messagesTable = 'messages', pricingOverride = null, project = '333method') { 222 // Fetch site metadata 223 const site = await getOne( 224 `SELECT id, domain, score, grade, country_code, city, 225 conversation_status 226 FROM sites WHERE id = $1`, 227 [inbound.site_id] 228 ); 229 230 if (!site) { 231 throw new Error(`Site #${inbound.site_id} not found`); 232 } 233 234 // Fetch prior messages in thread order 235 const priorMessages = await getAll( 236 `SELECT direction, contact_method, message_body, subject_line, 237 message_type, sent_at, created_at 238 FROM ${messagesTable} 239 WHERE site_id = $1 240 ORDER BY created_at ASC`, 241 [inbound.site_id] 242 ); 243 244 // Fetch the original outreach proposal text 245 const originalOutreach = await getOne( 246 `SELECT message_body, subject_line 247 FROM ${messagesTable} 248 WHERE site_id = $1 AND direction = 'outbound' AND message_type = 'outreach' 249 AND sent_at IS NOT NULL 250 ORDER BY sent_at ASC 251 LIMIT 1`, 252 [inbound.site_id] 253 ); 254 255 // Parse score_json for weaknesses (filesystem first, DB fallback) 256 let weaknesses = []; 257 const scoreData = getScoreDataWithFallback(site.id, site); 258 if (scoreData) { 259 try { 260 if (scoreData && typeof scoreData === 'object') { 261 const entries = Object.entries(scoreData) 262 .filter(([, v]) => typeof v === 'number' || (typeof v === 'object' && v?.score !== null && v?.score !== undefined)) 263 .map(([key, v]) => ({ 264 category: key, 265 score: typeof v === 'number' ? v : v.score, 266 details: typeof v === 'object' ? v.details || v.reason || '' : '', 267 })) 268 .filter(e => e.score < 70) 269 .sort((a, b) => a.score - b.score); 270 weaknesses = entries; 271 } 272 } catch (_parseErr) { 273 logger.warn(`Failed to parse score_json for site #${site.id}`); 274 } 275 } 276 277 // Country pricing (uses override if provided, otherwise DEFAULT_PRICING) 278 const countryConfig = getCountryByCode(site.country_code); 279 const pricing = getPricing(site.country_code, pricingOverride); 280 const funnelStage = classifyFunnelStage(inbound); 281 282 return { 283 project, 284 site: { 285 id: site.id, 286 domain: site.domain, 287 score: site.score, 288 grade: site.grade, 289 countryCode: site.country_code, 290 city: site.city, 291 conversationStatus: site.conversation_status, 292 }, 293 weaknesses, 294 weaknessCount: weaknesses.length, 295 scoreData, 296 pricing, 297 countryConfig, 298 priorMessages, 299 originalOutreach, 300 inbound: { 301 text: inbound.message_body, 302 intent: inbound.intent, 303 sentiment: inbound.sentiment, 304 channel: inbound.contact_method, 305 }, 306 funnelStage, 307 }; 308 } 309 310 /** 311 * Generate reply text using Claude Opus 312 * 313 * @param {Object} context - Built context from buildContext() 314 * @returns {Promise<string|null>} Reply text, or null if LLM chose to skip 315 */ 316 export async function generateReply(context) { 317 const project = context.project || '333method'; 318 const projConfig = PROJECT_CONFIG[project] || PROJECT_CONFIG['333method']; 319 const promptFile = loadPromptFile(project); 320 321 const channelConstraint = 322 context.inbound.channel === 'sms' 323 ? 'SMS channel: keep reply concise (under 320 chars for 2 segments). No greetings.' 324 : 'Email channel: 2-4 sentences, conversational and warm.'; 325 326 // Build conversation thread 327 const threadSummary = 328 context.priorMessages.length > 0 329 ? context.priorMessages 330 .map(m => { 331 const dir = m.direction === 'inbound' ? 'THEM' : 'US'; 332 const text = (m.message_body || '').slice(0, 300); 333 return `[${dir}] ${text}`; 334 }) 335 .join('\n') 336 : ''; 337 338 // Project-specific context sections 339 let projectContextBlock; 340 if (project === '2step') { 341 // 2Step: video reviews — no website score/weaknesses, focus on video + reviews 342 projectContextBlock = ` 343 CURRENT CONVERSATION CONTEXT: 344 - Project: 2Step Video Reviews 345 - Business: ${context.site.domain} 346 - Country: ${context.site.countryCode || 'unknown'} 347 - City: ${context.site.city || 'unknown'} 348 - Service: ${projConfig.service}`; 349 } else { 350 // 333Method: website audit — include score, grade, weaknesses 351 let weaknessInfo = 352 'No specific weaknesses available — give a general observation about the site.'; 353 if (context.weaknesses.length > 0) { 354 const topWeakness = context.weaknesses[0]; 355 weaknessInfo = `Top weakness: "${topWeakness.category}" (score: ${topWeakness.score}/100)${topWeakness.details ? ` — ${topWeakness.details}` : ''}`; 356 if (context.weaknesses.length > 1) { 357 weaknessInfo += `\nOther weak areas: ${context.weaknesses 358 .slice(1, 4) 359 .map(w => `${w.category} (${w.score})`) 360 .join(', ')}`; 361 } 362 } 363 364 projectContextBlock = ` 365 CURRENT CONVERSATION CONTEXT: 366 - Project: 333Method (Audit & Fix) 367 - Domain: ${context.site.domain} 368 - Grade: ${context.site.grade || 'N/A'} (Score: ${context.site.score || 'N/A'}/100) 369 - Country: ${context.site.countryCode || 'unknown'} 370 - City: ${context.site.city || 'unknown'} 371 - Total issues found: ${context.weaknessCount} 372 - ${weaknessInfo}`; 373 } 374 375 // Pricing section (only shown at qualified stage) 376 let pricingBlock = ''; 377 if (context.funnelStage === 'qualified') { 378 const paymentUrl = `${projConfig.paymentUrlPrefix}${context.site.id}`; 379 pricingBlock = `PRICING: ${context.pricing.symbol}${context.pricing.amount} ${context.pricing.currency}\nPAYMENT LINK: ${paymentUrl}`; 380 } 381 382 const systemPrompt = `${promptFile || projConfig.defaultPrompt} 383 ${projectContextBlock} 384 385 FUNNEL STAGE: ${context.funnelStage} 386 ${pricingBlock} 387 388 CONVERSATION THREAD: 389 ${threadSummary || '(No prior messages)'} 390 391 ${channelConstraint}`; 392 393 const userMessage = `<untrusted_content> 394 Their latest message: "${context.inbound.text}" 395 </untrusted_content> 396 397 Funnel stage: ${context.funnelStage}. Respond with valid JSON only.`; 398 399 // Use OpenRouter for autoresponder LLM calls (Claude Opus) 400 const openrouterKey = process.env.OPENROUTER_API_KEY; 401 402 if (!openrouterKey) { 403 throw new Error('No LLM provider available (set OPENROUTER_API_KEY)'); 404 } 405 406 const model = 'anthropic/claude-opus-4'; 407 const providerUsed = 'openrouter'; 408 409 const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { 410 method: 'POST', 411 headers: { 412 Authorization: `Bearer ${openrouterKey}`, 413 'Content-Type': 'application/json', 414 'HTTP-Referer': 'https://auditandfix.com', 415 'X-Title': 'Audit&Fix Autoresponder', 416 }, 417 body: JSON.stringify({ 418 model, 419 messages: [ 420 { role: 'system', content: systemPrompt }, 421 { role: 'user', content: userMessage }, 422 ], 423 max_tokens: 300, 424 temperature: 0.7, 425 }), 426 }); 427 428 if (!response.ok) { 429 throw new Error(`OpenRouter API error: ${response.status} ${response.statusText}`); 430 } 431 432 const data = await response.json(); 433 const replyText = data.choices?.[0]?.message?.content?.trim(); 434 const usage = { 435 promptTokens: data.usage?.prompt_tokens || 0, 436 completionTokens: data.usage?.completion_tokens || 0, 437 }; 438 439 if (!replyText) { 440 throw new Error('Empty response from OpenRouter API'); 441 } 442 443 // Log LLM usage 444 try { 445 await logLLMUsage({ 446 siteId: context.site.id, 447 stage: 'replies', 448 provider: providerUsed, 449 model, 450 promptTokens: usage.promptTokens, 451 completionTokens: usage.completionTokens, 452 }); 453 } catch (err) { 454 logger.warn(`Failed to log LLM usage: ${err.message}`); 455 } 456 457 // Parse JSON output from the LLM 458 let parsedReply; 459 try { 460 const cleaned = replyText 461 .replace(/^```json\s*\n?/i, '') 462 .replace(/\n?```\s*$/i, '') 463 .trim(); 464 parsedReply = JSON.parse(cleaned); 465 } catch (_parseErr) { 466 logger.warn('LLM response was not valid JSON — using as plain text reply'); 467 parsedReply = { reply: replyText, skip: false }; 468 } 469 470 // Handle skip responses (LLM decided not to reply) 471 if (parsedReply.skip) { 472 logger.info(`LLM chose to skip reply (reason: ${parsedReply.skip_reason || 'unspecified'})`); 473 return null; 474 } 475 476 return parsedReply.reply || replyText; 477 } 478 479 /** 480 * Send the auto-generated reply 481 * 482 * @param {Object} inbound - Original inbound message row 483 * @param {string} replyText - Reply text to send 484 * @param {string} [messagesTable='messages'] - Table name for message inserts/updates 485 * @returns {Promise<{ sent: boolean, messageId?: number, reason?: string, error?: string }>} 486 */ 487 export async function sendReply(inbound, replyText, messagesTable = 'messages') { 488 const siteId = inbound.site_id; 489 const channel = inbound.contact_method; 490 const contactUri = inbound.contact_uri; 491 492 // Business hours check for SMS (TCPA compliance) 493 if (channel === 'sms') { 494 const timezone = await getRecipientTimezone(siteId); 495 if (!isBusinessHours(timezone)) { 496 logger.info(`Skipping SMS reply to ${contactUri} — outside business hours in ${timezone}`); 497 return { sent: false, reason: 'outside_business_hours' }; 498 } 499 } 500 501 // Insert the outbound reply into messages table 502 const result = await run( 503 `INSERT INTO ${messagesTable} ( 504 site_id, direction, contact_method, contact_uri, 505 message_body, message_type, delivery_status 506 ) VALUES ($1, 'outbound', $2, $3, $4, 'reply', 'queued') 507 RETURNING id`, 508 [siteId, channel, contactUri, replyText] 509 ); 510 511 const messageId = result.lastInsertRowid; 512 logger.info(`Created autoresponder reply message #${messageId} for site #${siteId}`); 513 514 // Send via the appropriate channel 515 try { 516 if (channel === 'sms') { 517 const { sendSMS } = await import('../outreach/sms.js'); 518 await sendSMS(messageId); 519 } else if (channel === 'email') { 520 const { sendEmail } = await import('../outreach/email.js'); 521 await sendEmail(messageId); 522 } else { 523 logger.warn( 524 `Unsupported reply channel: ${channel} — message #${messageId} queued but not sent` 525 ); 526 return { sent: false, messageId, reason: 'unsupported_channel' }; 527 } 528 529 // Mark as sent 530 await run( 531 `UPDATE ${messagesTable} SET sent_at = CURRENT_TIMESTAMP, delivery_status = 'sent', 532 updated_at = CURRENT_TIMESTAMP WHERE id = $1`, 533 [messageId] 534 ); 535 536 logger.success( 537 `Sent autoresponder reply via ${channel} to ${contactUri} (message #${messageId})` 538 ); 539 return { sent: true, messageId }; 540 } catch (err) { 541 logger.error(`Failed to send autoresponder reply #${messageId}: ${err.message}`); 542 543 await run( 544 `UPDATE ${messagesTable} SET delivery_status = 'failed', error_message = $1, 545 updated_at = CURRENT_TIMESTAMP WHERE id = $2`, 546 [err.message.slice(0, 500), messageId] 547 ); 548 549 return { sent: false, messageId, error: err.message }; 550 } 551 } 552 553 /** 554 * Process the inbound queue — find messages needing auto-replies and handle them 555 * 556 * @param {Object} [config={}] - Optional injection config for cross-project use 557 * @param {string} [config.messagesTable='messages'] - Table name (supports schema-qualified, e.g. 'msgs.messages') 558 * @param {Object|Function} [config.pricing] - Pricing override (plain object or function) 559 * @param {string} [config.project='333method'] - Project identifier for logging 560 * @returns {Promise<{ processed: number, sent: number, skipped: number, failed: number }>} 561 */ 562 export async function processInboundQueue(config = {}) { 563 if (process.env.AUTORESPONDER_ENABLED === 'false') { 564 logger.info('Autoresponder disabled (AUTORESPONDER_ENABLED=false)'); 565 return { processed: 0, sent: 0, skipped: 0, failed: 0 }; 566 } 567 568 const messagesTable = config.messagesTable || 'messages'; 569 const pricingOverride = config.pricing || null; 570 const project = config.project || '333method'; 571 572 logger.info(`[${project}] Processing inbound queue (table: ${messagesTable})`); 573 574 const stats = { processed: 0, sent: 0, skipped: 0, failed: 0 }; 575 576 // Find inbound messages that have not been auto-replied to yet 577 const inboundMessages = await getAll( 578 `SELECT m.id, m.site_id, m.contact_method, m.contact_uri, 579 m.message_body, m.intent, m.sentiment, m.created_at 580 FROM ${messagesTable} m 581 JOIN sites s ON m.site_id = s.id 582 WHERE m.direction = 'inbound' 583 AND m.contact_method IN ('sms', 'email') 584 AND m.created_at > NOW() - INTERVAL '72 hours' 585 AND NOT EXISTS ( 586 SELECT 1 FROM ${messagesTable} r 587 WHERE r.site_id = m.site_id 588 AND r.direction = 'outbound' 589 AND r.message_type = 'reply' 590 AND r.sent_at IS NOT NULL 591 AND r.created_at >= m.created_at 592 ) 593 ORDER BY m.created_at ASC`, 594 [] 595 ); 596 597 if (inboundMessages.length === 0) { 598 logger.info('No inbound messages needing auto-reply'); 599 return stats; 600 } 601 602 logger.info(`Found ${inboundMessages.length} inbound messages to process`); 603 604 // Process sequentially to avoid double-replies 605 for (const inbound of inboundMessages) { 606 stats.processed++; 607 608 try { 609 if (!(await shouldAutoRespond(inbound, messagesTable))) { 610 logger.info(`Skipping message #${inbound.id} (shouldAutoRespond=false)`); 611 stats.skipped++; 612 continue; 613 } 614 615 // Build context (pass messagesTable, pricingOverride, and project for cross-project use) 616 const context = await buildContext(inbound, messagesTable, pricingOverride, project); 617 618 // Skip autoresponder / out-of-office messages 619 if (context.funnelStage === 'autoresponder') { 620 logger.info(`Skipping autoresponder/OOO message #${inbound.id}`); 621 stats.skipped++; 622 continue; 623 } 624 625 // Handle opt-out (Stage 5): update conversation status 626 if (context.funnelStage === 'not_interested') { 627 await run( 628 `UPDATE sites SET conversation_status = 'not_interested', 629 updated_at = CURRENT_TIMESTAMP WHERE id = $1`, 630 [inbound.site_id] 631 ); 632 } 633 634 // Generate reply via LLM 635 const replyText = await generateReply(context); 636 637 if (!replyText) { 638 logger.warn(`Empty reply generated for message #${inbound.id}, skipping`); 639 stats.skipped++; 640 continue; 641 } 642 643 // Send the reply (pass messagesTable for insert/update) 644 const sendResult = await sendReply(inbound, replyText, messagesTable); 645 646 if (sendResult.sent) { 647 stats.sent++; 648 const preview = replyText.slice(0, 50).replace(/\n/g, ' '); 649 logger.success( 650 `[${project}] Auto-replied to ${inbound.contact_uri} (site #${inbound.site_id}, ` + 651 `${context.funnelStage}, ${inbound.contact_method}): "${preview}..."` 652 ); 653 } else if (sendResult.reason === 'outside_business_hours') { 654 stats.skipped++; 655 } else { 656 stats.failed++; 657 } 658 } catch (err) { 659 logger.error(`Error processing inbound message #${inbound.id}: ${err.message}`); 660 stats.failed++; 661 // Continue processing other messages 662 } 663 } 664 665 logger.success( 666 `Autoresponder complete: ${stats.sent} sent, ${stats.skipped} skipped, ${stats.failed} failed out of ${stats.processed} processed` 667 ); 668 669 return stats; 670 } 671 672 /* c8 ignore start */ 673 // CLI 674 if (import.meta.url === `file://${process.argv[1]}`) { 675 processInboundQueue() 676 .then(result => { 677 console.log( 678 `\nAutoresponder: ${result.sent} sent, ${result.skipped} skipped, ${result.failed} failed\n` 679 ); 680 process.exit(0); 681 }) 682 .catch(err => { 683 logger.error('Autoresponder failed', err); 684 process.exit(1); 685 }); 686 } 687 /* c8 ignore stop */ 688 689 export default { 690 shouldAutoRespond, 691 classifyFunnelStage, 692 buildContext, 693 generateReply, 694 sendReply, 695 processInboundQueue, 696 };