Skip to content
// Load each contact's conversation, drop duplicates (Meta retries webhooks), and decide what to do.
// Rules handle what must never go wrong (opt-out, safety, human handoff); everything else is an "answer".
const s = $('Settings (edit me)').first().json;
const db = $getWorkflowStaticData('global');
db.convos ??= {};

const lower = (t) => t.toLowerCase();
const has = (t, words) => words.some((w) => new RegExp(`\\b${w}\\b`, 'i').test(t));

function openNow(nowIso) {
  const local = DateTime.fromISO(nowIso).setZone(s.timezone);
  const h = s.hours[local.toFormat('ccc').toLowerCase()]; // e.g. { open: 8, close: 18 }
  return { open: Boolean(h) && local.hour >= h.open && local.hour < h.close, local: local.toFormat("ccc HH:mm") };
}

// Rule-based reply used when AI is off or fails. Fills the lead step by step.
function rulesAnswer(c, text, profileName) {
  const t = lower(text);
  const patch = {};
  const service = s.services.find((sv) => sv.keywords.some((k) => t.includes(k)));
  if (service) patch.service = service.name;
  const zip = text.match(/\b\d{5}\b/)?.[0];
  if (zip) patch.zip = zip;
  if (c.awaiting === 'name' && text.length <= 40 && !zip) patch.name = text.trim();
  if (c.awaiting === 'preferred_time') patch.preferred_time = text.trim().slice(0, 80);
  const when = t.match(/\b(today|tomorrow|monday|tuesday|wednesday|thursday|friday|saturday|morning|afternoon|evening|asap)\b/);
  if (when && !patch.preferred_time && c.lead.service) patch.preferred_time = text.trim().slice(0, 80);

  const lead = { ...c.lead, ...patch };
  if (!lead.name && profileName) lead.name = profileName;
  const booking = Object.keys(patch).length > 0 || has(t, ['book', 'schedule', 'appointment', 'technician', 'quote', 'estimate', 'repair', 'fix', 'broken', 'not working', 'come out']);
  const faq = s.faq.find((f) => f.keywords.some((k) => t.includes(k)));
  const first = (lead.name || profileName || '').split(' ')[0];

  if (booking || (c.awaiting && Object.keys(patch).length)) {
    if (lead.zip && !s.service_zips.includes(lead.zip))
      return { reply: `Sorry, ${lead.zip} is outside our service area (${s.service_area}). ${faq ? faq.answer : ''}`.trim(), patch: { ...patch, name: lead.name }, awaiting: null, intent: 'out_of_area' };
    const ask = {
      service: `Happy to help! What do you need: ${s.services.map((x) => x.name).join(', ')}?`,
      zip: `Got it: ${lead.service}. What's the ZIP code for the visit?`,
      name: 'Thanks! Who should the technician ask for?',
      preferred_time: `Thanks${first ? ' ' + first : ''}! When works best? (e.g. "tomorrow morning")`,
    };
    const missing = ['service', 'zip', 'name', 'preferred_time'].find((k) => !lead[k]);
    if (missing) return { reply: (faq ? faq.answer + '\n\n' : '') + ask[missing], patch: { ...patch, name: lead.name }, awaiting: missing, intent: 'booking' };
    return {
      reply: `Thanks ${first}! I've sent your ${lead.service} request for ${lead.zip} (${lead.preferred_time}) to our team. They'll confirm here within ${s.confirm_within}.`,
      patch: { ...patch, name: lead.name }, awaiting: null, intent: 'booking',
    };
  }
  if (faq) return { reply: faq.answer, patch, awaiting: c.awaiting, intent: 'faq' };
  if (has(t, ['hi', 'hello', 'hey', 'hola', 'good morning', 'good afternoon']))
    return { reply: `Hi${first ? ' ' + first : ''}! This is ${s.business_name}. I can answer questions (hours, prices, service area) or book a technician. What can I help with?`, patch, awaiting: null, intent: 'greeting' };
  return { reply: `I'm not sure about that one. I can help with hours, prices, our service area or booking a visit. Reply HUMAN to talk to our team.`, patch, awaiting: c.awaiting, intent: 'unknown' };
}

const out = [];
for (const item of $input.all()) {
  const m = item.json;
  const now = m.now;
  const c = JSON.parse(JSON.stringify(db.convos[m.wa_id] ?? {
    wa_id: m.wa_id, name: '', created_at: now, history: [], seen: [], lead: {}, awaiting: null,
    opted_out: false, handoff_until: null, lead_captured_at: null,
  })); // copy (structuredClone isn't available in the Code sandbox)
  if (m.profile_name && !c.name) c.name = m.profile_name;

  const duplicate = c.seen.includes(m.message_id);
  const hours = openNow(now);
  const t = lower(m.text);
  let action, reply = '', rules = null;

  if (duplicate) action = 'duplicate';
  else if (/^\s*(stop|unsubscribe)\s*$/i.test(m.text)) { action = 'opt_out'; reply = `You're unsubscribed from ${s.business_name} messages. Reply START to resume.`; }
  else if (/^\s*start\s*$/i.test(m.text)) { action = 'opt_in'; reply = `Welcome back! How can ${s.business_name} help?`; }
  else if (c.opted_out) action = 'ignore';
  else if (has(t, s.emergency_keywords)) {
    action = 'emergency';
    reply = `⚠️ If you smell gas or a CO alarm is going off, leave the home now and call 911 or your gas company from outside. Don't use switches or appliances. I've alerted our team; we'll call you back at this number.`;
  }
  else if (c.handoff_until && c.handoff_until > now) action = 'silent'; // a human is handling this chat
  else if (has(t, ['human', 'real person', 'agent', 'representative', 'talk to someone', 'speak to someone', 'talk to a person', 'speak to a person', 'call me'])) {
    action = 'handoff';
    reply = hours.open
      ? `Sure, I've asked a team member to take over. They'll reply here shortly.`
      : `Sure, I've passed this to our team. We're closed right now (${s.hours_text}); someone will reply here when we open.`;
  }
  else if (m.type !== 'text' && m.type !== 'interactive' && m.type !== 'button' && !m.text) {
    action = 'unsupported';
    reply = `Thanks! I can only read text messages right now. Could you type your question?`;
  }
  else { action = 'answer'; rules = rulesAnswer(c, m.text, c.name); }

  out.push({
    json: {
      ...m, action, reply, duplicate, open_now: hours.open, local_time: hours.local,
      rules, convo: c, use_ai: action === 'answer' && s.use_ai,
      history_text: c.history.slice(-s.memory_turns).map((h) => `${h.role === 'user' ? 'Customer' : 'Assistant'}: ${h.text}`).join('\n'),
    },
  });
}
return out;