WhatsApp AI Assistant
Module 20 · ai · intermediate
state.js
// 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;