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