/ 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        .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  };