/ src / inbound / autoresponder.js
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  };